Compare commits

...

799 Commits

Author SHA1 Message Date
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
Egor 03fc4abd2a Update subscription.py 2025-09-03 04:32:16 +03:00
Egor f280a37818 Update subscription.py 2025-09-03 04:25:31 +03:00
Egor f100ec62d4 Update subscription.py 2025-09-03 04:24:55 +03:00
Egor cd827612d9 Update subscription.py 2025-09-03 04:23:41 +03:00
Egor 50da2f82c8 Update subscription_service.py 2025-09-03 04:22:05 +03:00
Egor 4c814ec5f4 Update inline.py 2025-09-03 04:13:35 +03:00
Egor fb8c02d5ab Update subscription.py 2025-09-03 04:12:34 +03:00
Egor 465ad3d1ad Update inline.py 2025-09-03 04:10:55 +03:00
Egor 2bfaab7d16 Update subscription_service.py 2025-09-03 04:07:33 +03:00
Egor e356542df7 Update subscription.py 2025-09-03 00:46:11 +03:00
Egor a3dfbdce3b Update inline.py 2025-09-03 00:44:57 +03:00
Egor a666e8d248 Update subscription_service.py 2025-09-03 00:44:18 +03:00
Egor 9ef8df22a8 Update subscription.py 2025-09-03 00:43:54 +03:00
Egor a1471c8b9a Update subscription.py 2025-09-03 00:39:35 +03:00
Egor a99a14922c Update subscription_service.py 2025-09-03 00:17:11 +03:00
Egor ce954ad775 Update subscription.py 2025-09-03 00:15:42 +03:00
Egor efba7c10d0 Update subscription.py 2025-09-03 00:15:01 +03:00
Egor cd10c131b2 Update inline.py 2025-09-03 00:14:30 +03:00
Egor 3e4cb8dfa2 Update inline.py 2025-09-02 23:38:52 +03:00
Egor 6471ab547c Update subscription.py 2025-09-02 23:37:57 +03:00
Egor c10bf07124 Update inline.py 2025-09-02 23:32:14 +03:00
Egor 4e05882522 Update subscription.py 2025-09-02 23:30:51 +03:00
Egor 061b292d92 Update subscription.py 2025-09-02 23:28:30 +03:00
Egor 779a6c309a Update subscription.py 2025-09-02 23:24:16 +03:00
Egor c79c71f209 Update remnawave_api.py 2025-09-02 17:35:45 +03:00
Egor d4dbce5606 Update remnawave_api.py 2025-09-02 17:32:41 +03:00
Egor e53d99223d Update config.py 2025-09-02 15:32:45 +03:00
Egor ec7274e0c9 Update remnawave_api.py 2025-09-02 15:32:29 +03:00
Egor 8fe631a98e Update remnawave_api.py 2025-09-02 15:26:00 +03:00
Egor bd831bd92b Update config.py 2025-09-02 15:21:43 +03:00
Egor 9b31ba8f3d Update remnawave_api.py 2025-09-02 15:21:15 +03:00
Egor 3759be333a Update remnawave_api.py 2025-09-02 15:15:25 +03:00
Egor 555894b26c Update README.md 2025-09-02 14:55:47 +03:00
Egor bb636c003c Update .env.example 2025-09-02 14:50:48 +03:00
Egor 40c7b4aebd Update remnawave_api.py 2025-09-02 14:23:38 +03:00
Egor 028bd4888b Update remnawave_api.py 2025-09-02 14:10:01 +03:00
Egor 27d64e361a Update remnawave_api.py 2025-09-02 14:03:04 +03:00
Egor 6fc7dd49dd Update remnawave_api.py 2025-09-02 13:40:32 +03:00
Egor b835ba1d65 Update remnawave_api.py 2025-09-02 13:33:15 +03:00
Egor 811e985087 Update .env.example 2025-09-02 13:29:29 +03:00
Egor c16733b93d Update config.py 2025-09-02 13:29:13 +03:00
Egor 23ab3c5198 Update remnawave_service.py 2025-09-02 13:28:09 +03:00
Egor 9b8f121e69 Update remnawave_api.py 2025-09-02 13:25:49 +03:00
Egor 86b1b95fb9 Update subscription.py 2025-09-02 08:52:42 +03:00
Egor 9418681a00 Update subscription_service.py 2025-09-02 08:50:35 +03:00
Egor 7ebd937e2c Update README.md 2025-09-02 02:21:40 +03:00
Egor 25c404f999 Update README.md 2025-09-02 02:09:29 +03:00
Egor 6df92bde2a Update README.md 2025-09-02 00:34:13 +03:00
Egor 60ff006fec Update README.md 2025-09-02 00:33:03 +03:00
Egor 0181f5e10c Update docker-compose.yml 2025-09-02 00:30:33 +03:00
Egor 4bbcc60bbb Update subscriptions.py 2025-09-02 00:16:07 +03:00
Egor 8e2138e2aa Update subscriptions.py 2025-09-02 00:14:45 +03:00
Egor 70b9e8c10d Update inline.py 2025-09-01 23:44:56 +03:00
Egor dc1eddb0df Update subscription.py 2025-09-01 23:44:02 +03:00
Egor 630551cc4b Update README.md 2025-09-01 23:17:46 +03:00
Egor 4f94b25182 Update docker-compose.yml 2025-09-01 22:50:17 +03:00
Egor d3b240eae3 Update Dockerfile 2025-09-01 22:49:41 +03:00
Egor e2883d6727 Update main.py 2025-09-01 22:29:47 +03:00
Egor 21757b37ab Update bot.py 2025-09-01 22:27:37 +03:00
Egor 9190857ffa Update maintenance_service.py 2025-09-01 22:27:08 +03:00
Egor a607443aa7 Update README.md 2025-09-01 19:59:39 +03:00
Egor 41969bc0c2 Update .env.example 2025-09-01 19:59:20 +03:00
Egor 0d443afb87 Update balance.py 2025-09-01 19:58:21 +03:00
Egor 3d85294d41 Update config.py 2025-09-01 19:55:23 +03:00
Egor 86896771a6 Update inline.py 2025-09-01 05:02:10 +03:00
Egor 42d52990fa Update README.md 2025-09-01 04:34:16 +03:00
Egor 6296c8257f Update README.md 2025-09-01 04:13:29 +03:00
Egor 14a40cb1b6 Create docker-compose.yml 2025-09-01 04:10:55 +03:00
Egor d0e3650de5 Update config.py 2025-09-01 03:52:56 +03:00
Egor df8ed666ae Update menu.py 2025-09-01 03:51:29 +03:00
Egor 2217b7f800 Update config.py 2025-09-01 03:46:13 +03:00
Egor 924febc2f3 Update menu.py 2025-09-01 03:43:27 +03:00
Egor 729ffb7952 Update messages.py 2025-09-01 01:06:54 +03:00
Egor 1568ca7633 Update README.md 2025-09-01 00:11:27 +03:00
Egor 7d5e476580 Update referral.py 2025-08-31 23:18:00 +03:00
Egor 9bcd187358 Update referral.py 2025-08-31 23:14:44 +03:00
Egor a9cbb6272f Update referral.py 2025-08-31 23:07:27 +03:00
Egor b524decfa7 Update referral.py 2025-08-31 23:04:00 +03:00
Egor 5ee01f7735 Update referral.py 2025-08-31 23:00:20 +03:00
Egor f46f2d6523 Update start.py 2025-08-31 22:45:54 +03:00
Egor 435dbf6cee Update auth.py 2025-08-31 22:42:02 +03:00
Egor 94d38eeb71 Update auth.py 2025-08-31 22:41:31 +03:00
Egor 0e7f6f90ed Update auth.py 2025-08-31 22:40:41 +03:00
Egor f3789287dc Update start.py 2025-08-31 22:40:04 +03:00
Egor a8312d7c10 Update promocode.py 2025-08-31 22:10:21 +03:00
Egor 612f4a7978 Update promocodes.py 2025-08-31 22:07:04 +03:00
Egor 2462347b5f Update promocode.py 2025-08-31 22:06:15 +03:00
Egor 3ec3c0d76b Update promocodes.py 2025-08-31 22:05:16 +03:00
Egor 8224aa98f4 Update promocodes.py 2025-08-31 22:02:00 +03:00
Egor bb9d9422fb Update promocode.py 2025-08-31 22:01:16 +03:00
Egor f3a184ff00 Update promocodes.py 2025-08-31 21:53:25 +03:00
Egor 866e4586df Update promocode.py 2025-08-31 21:52:18 +03:00
Egor a30647893d Update promocodes.py 2025-08-31 21:39:14 +03:00
Egor 73826312e5 Update promocodes.py 2025-08-31 19:08:16 +03:00
Egor dae34dc0ef Update promocodes.py 2025-08-31 18:59:39 +03:00
Egor 17d0c8c26f Update promocodes.py 2025-08-31 18:50:55 +03:00
Egor 67831dc162 Update promocodes.py 2025-08-31 18:45:16 +03:00
Egor ae84fbe141 Update promocodes.py 2025-08-31 18:36:54 +03:00
Egor 8c9bd890e8 Update promocodes.py 2025-08-31 18:35:57 +03:00
Egor e8c1956de5 Update promocodes.py 2025-08-31 18:28:06 +03:00
Egor 1540a41d3a Update .env.example 2025-08-31 17:40:08 +03:00
Egor 9cb61cb696 Update README.md 2025-08-31 17:39:52 +03:00
Egor 3511325285 Update auth.py 2025-08-31 16:18:42 +03:00
Egor 62507c5d28 Update auth.py 2025-08-31 16:10:28 +03:00
Egor 387df29a05 Update start.py 2025-08-31 15:40:31 +03:00
Egor 75dca7e1ad Update start.py 2025-08-31 15:32:25 +03:00
Egor 2d5e54d5ad Update README.md 2025-08-31 06:52:42 +03:00
Egor d62b2b0d6f Update README.md 2025-08-31 06:50:22 +03:00
Egor e273719ad1 Update README.md 2025-08-31 06:49:41 +03:00
Egor f6726f1c94 Update README.md 2025-08-31 06:15:32 +03:00
Egor 92ff4f661f Update README.md 2025-08-31 05:54:54 +03:00
Egor dfebc5e608 Update .gitignore 2025-08-31 05:52:00 +03:00
Egor 8e6121cd0b Update README.md 2025-08-31 05:48:08 +03:00
Egor 6c0d5714bd Update remnawave_service.py 2025-08-31 05:19:43 +03:00
Egor ed29697434 Update remnawave_service.py 2025-08-31 05:18:56 +03:00
Egor 07aebef82f Update remnawave_service.py 2025-08-31 05:16:17 +03:00
Egor 7ce390fd50 Update remnawave_service.py 2025-08-31 05:09:31 +03:00
Egor cebe392d1c Update remnawave_service.py 2025-08-31 04:55:19 +03:00
Egor fd160f585a Update remnawave_service.py 2025-08-31 04:50:31 +03:00
Egor 5c0516fbc0 Update remnawave_service.py 2025-08-31 04:45:25 +03:00
Egor ba075326c1 Update admin.py 2025-08-31 04:39:08 +03:00
Egor c32425341e Update remnawave.py 2025-08-31 04:38:20 +03:00
Egor cc8df1eff3 Update remnawave_service.py 2025-08-31 04:33:56 +03:00
Egor 49cda0791c Update .env.example 2025-08-31 04:25:10 +03:00
Egor 60ea363a62 Update README.md 2025-08-31 04:24:37 +03:00
Egor f66619b225 Update subscription.py 2025-08-31 04:19:18 +03:00
Egor 5999402e1a Update inline.py 2025-08-31 04:17:18 +03:00
Egor 3aebccc090 Update inline.py 2025-08-31 04:16:37 +03:00
Egor ed577dd51a Update config.py 2025-08-31 04:15:45 +03:00
Egor e517487596 Update main.py 2025-08-31 04:03:37 +03:00
Egor f9441909a4 Update balance.py 2025-08-31 02:42:33 +03:00
Egor f13a015bfa Update payment_service.py 2025-08-31 00:47:07 +03:00
Egor 18bf946464 Update yookassa_webhook.py 2025-08-31 00:39:04 +03:00
Egor e4c46b2488 Update yookassa_webhook.py 2025-08-31 00:33:37 +03:00
Egor e0539b116c Update yookassa_webhook.py 2025-08-31 00:28:04 +03:00
Egor acdf7af1d9 Update yookassa_webhook.py 2025-08-31 00:22:18 +03:00
Egor d5b7828079 Update yookassa_webhook.py 2025-08-31 00:12:53 +03:00
Egor 03be071edc Update webhook_server.py 2025-08-30 23:54:27 +03:00
Egor e8742f30a6 Update webhook_server.py 2025-08-30 23:51:25 +03:00
Egor 3d7727dbf2 Update webhook_server.py 2025-08-30 23:46:31 +03:00
Egor 5bff67860f Update tribute.py 2025-08-30 23:45:48 +03:00
Egor 429e967da2 Update tribute.py 2025-08-30 23:44:35 +03:00
Egor c3df3beeae Update tribute.py 2025-08-30 23:42:55 +03:00
Egor 4d664c15d7 Update yookassa_webhook.py 2025-08-30 23:37:58 +03:00
Egor 53be365e38 Update tribute.py 2025-08-30 23:36:58 +03:00
Egor 3c3ffed32e Update yookassa_webhook.py 2025-08-30 23:36:12 +03:00
Egor 015726b3a6 Update webhook_server.py 2025-08-30 23:35:20 +03:00
Egor 4a3267712b Update main.py 2025-08-30 23:34:38 +03:00
Egor 9b45aae410 Update main.py 2025-08-30 23:24:43 +03:00
Egor f43b391d0e Update referral_service.py 2025-08-30 23:22:13 +03:00
Egor b09b7a0c84 Add files via upload 2025-08-30 23:21:04 +03:00
Egor 541b44b933 Update payment_service.py 2025-08-30 19:32:55 +03:00
Egor b21fce0eff Update webhook_server.py 2025-08-30 19:23:58 +03:00
Egor 017aa27c0f Update webhook_server.py 2025-08-30 19:17:31 +03:00
Egor 496978e8a2 Update texts.py 2025-08-30 19:11:45 +03:00
Egor b3711a590d Update user.py 2025-08-30 19:08:50 +03:00
Egor 631a0f2c4c Update payment_service.py 2025-08-30 19:05:55 +03:00
Egor 503f7ca455 Update transaction.py 2025-08-30 19:01:34 +03:00
Egor 80c771b7bc Update texts.py 2025-08-30 18:59:08 +03:00
Egor 6e344d350b Update texts.py 2025-08-30 18:58:30 +03:00
Egor 7738bc83af Update user.py 2025-08-30 18:57:32 +03:00
Egor 6f7a1eecf6 Update transaction.py 2025-08-30 18:56:07 +03:00
Egor bf147a736a Update main.py 2025-08-30 18:51:43 +03:00
Egor ea3decf1ac Update payment_service.py 2025-08-30 18:49:30 +03:00
Egor b1c0315a6e Update Dockerfile 2025-08-30 18:46:10 +03:00
Egor f6fa7e8f41 Update main.py 2025-08-30 18:45:44 +03:00
Egor 33d4d7994c Update tribute.py 2025-08-30 18:44:45 +03:00
Egor 0ce4ce2b08 Update webhook_server.py 2025-08-30 18:43:32 +03:00
Egor 3a9163e919 Update maintenance_service.py 2025-08-30 17:58:52 +03:00
Egor 376cff748b Update subscription_service.py 2025-08-30 16:57:35 +03:00
Egor d5cef080da Update referral_service.py 2025-08-30 16:51:34 +03:00
Egor 33e31213b3 Update balance.py 2025-08-30 16:05:42 +03:00
Egor 8077ba0c35 Update maintenance_service.py 2025-08-30 15:50:01 +03:00
Egor 67ad3847f8 Update maintenance_service.py 2025-08-30 15:48:12 +03:00
Egor 1eda042b34 Update main.py 2025-08-30 15:40:32 +03:00
Egor 961ff46f43 Update maintenance_service.py 2025-08-30 15:39:43 +03:00
Egor 535ec34a1b Update maintenance_service.py 2025-08-30 15:15:42 +03:00
Egor fe474fd0bf Merge pull request #18 from Fr1ngg/MAINTENANCE_MODE
Maintenance mode
2025-08-30 14:50:04 +03:00
Egor bfc737c1d3 Update main.py 2025-08-30 14:47:40 +03:00
Egor ce0eb32d4e Update maintenance_service.py 2025-08-30 14:46:41 +03:00
Egor 090df2e3ea Update maintenance.py 2025-08-30 14:45:02 +03:00
Egor 01d9df2867 Update maintenance.py 2025-08-30 14:43:57 +03:00
Egor 334a14c80c Update bot.py 2025-08-30 14:41:55 +03:00
Egor 3d952570af Update README.md 2025-08-30 14:40:15 +03:00
Egor bf9567fe6f Update README.md 2025-08-30 14:37:22 +03:00
Egor 6c4c39b454 Add files via upload 2025-08-30 14:35:38 +03:00
Egor d39eb3f79f Update user.py 2025-08-30 13:00:32 +03:00
Egor 7f0ce61126 Update user.py 2025-08-30 05:00:44 +03:00
Egor 775056da6f Update user.py 2025-08-30 04:57:06 +03:00
Egor a15ecb375b Update user.py 2025-08-30 04:53:34 +03:00
Egor 3dea941c51 Update user_service.py 2025-08-30 04:49:50 +03:00
Egor 410f0aba95 Update user.py 2025-08-30 04:43:51 +03:00
Egor 1ec13fbf63 Update user.py 2025-08-30 04:39:57 +03:00
Egor 012408b7cd Update user.py 2025-08-30 04:36:23 +03:00
Egor fdca75b153 Update user.py 2025-08-30 04:35:19 +03:00
Egor 40c235cdac Update user_service.py 2025-08-30 04:26:52 +03:00
Egor 7ea8fa735c Update user.py 2025-08-30 04:26:25 +03:00
Egor 758141dbc4 Update user_service.py 2025-08-30 04:22:57 +03:00
Egor 3c37fa45e6 Update user_service.py 2025-08-30 04:17:01 +03:00
Egor 35a80f4585 Update user_service.py 2025-08-30 04:13:58 +03:00
Egor 57e033c7c6 Update config.py 2025-08-30 04:09:00 +03:00
Egor d05d070faa Update user_service.py 2025-08-30 04:07:09 +03:00
Egor 3f3829a745 Update tribute_service.py 2025-08-30 03:04:43 +03:00
Egor 21bb89e5ea Update transaction.py 2025-08-30 03:03:44 +03:00
Egor fad5ecbb89 Update tribute_service.py 2025-08-30 03:00:03 +03:00
Egor 2c64c57edd Update tribute_service.py 2025-08-30 02:53:15 +03:00
Egor 117c637756 Update user.py 2025-08-30 02:46:18 +03:00
Egor 488f161322 Update tribute_service.py 2025-08-30 02:45:00 +03:00
Egor a55ef684e6 Update user.py 2025-08-30 02:33:49 +03:00
Egor 90234ba01e Update tribute_service.py 2025-08-30 02:28:25 +03:00
Egor cd83361050 Update tribute_service.py 2025-08-30 02:22:02 +03:00
Egor 08cda481ba Update tribute.py 2025-08-30 02:17:47 +03:00
Egor 45f998d134 Update tribute.py 2025-08-29 19:46:07 +03:00
Egor 33ee5b4ec4 Update payment_service.py 2025-08-29 18:55:02 +03:00
Egor 545bbaafa5 Update balance.py 2025-08-29 18:38:59 +03:00
Egor cc957460bb Update balance.py 2025-08-29 18:21:32 +03:00
Egor cd51dab845 Update README.md 2025-08-29 12:29:43 +03:00
Egor 74923515cc Merge pull request #17 from Fr1ngg/YooKassa
YooKassa Интеграция
2025-08-29 11:59:37 +03:00
Egor b3f3c415df Update README.md 2025-08-29 11:56:46 +03:00
Egor 0b9de65cbc Update yookassa_service.py 2025-08-29 11:54:52 +03:00
Egor 1c26171b52 Update yookassa_webhook.py 2025-08-29 11:53:32 +03:00
Egor a6bc0afdd3 Update yookassa.py 2025-08-29 11:51:51 +03:00
Egor 966cfe6565 Update config.py 2025-08-29 11:50:12 +03:00
Egor d5b1c5805e Update requirements.txt 2025-08-29 11:48:31 +03:00
Egor a4ce5ff9ca Update .env.example 2025-08-29 11:47:46 +03:00
Egor 8e4b70578b Add files via upload 2025-08-29 11:46:33 +03:00
Egor 5367874062 Update models.py 2025-08-27 13:20:32 +03:00
Egor 321696b5b5 Update remnawave_api.py 2025-08-27 13:19:07 +03:00
Egor b642a5550c Update universal_migration.py 2025-08-27 13:16:13 +03:00
Egor 0e97632099 Update texts.py 2025-08-27 05:50:43 +03:00
Egor 4a3c6c4124 Update formatters.py 2025-08-27 05:50:18 +03:00
Egor 3bf9c143d4 Update monitoring_service.py 2025-08-27 05:49:44 +03:00
Egor 285e5e256e Update balance.py 2025-08-26 18:48:03 +03:00
Egor 5b06b89d7a bedolaga screens 2025-08-26 17:01:38 +03:00
Egor 7e749fa096 Merge pull request #16 from Fr1ngg/FixDbCollisions
Fix db collisions
2025-08-26 03:27:43 +03:00
Egor 13ac3f0e74 Create subscription_utils.py 2025-08-26 03:22:47 +03:00
Egor 32c551999c Update models.py 2025-08-26 03:21:26 +03:00
Egor ff2c09d3be Update subscription.py 2025-08-26 03:20:47 +03:00
Egor 4c97ac4497 Update subscription.py 2025-08-26 03:20:05 +03:00
Egor 86516186d9 Update main.py 2025-08-26 03:18:31 +03:00
Egor e0581bdc3c Create universal_migration.py 2025-08-26 03:14:26 +03:00
Egor 0b7d8d41f6 Update monitoring_service.py 2025-08-25 05:47:31 +03:00
Egor 4ca46537f9 Update subscription_service.py 2025-08-25 05:06:28 +03:00
Egor 75f2d0f1ce Update remnawave.py 2025-08-25 05:05:13 +03:00
Egor 51424bf256 Update user.py 2025-08-25 05:04:01 +03:00
Egor 4cce2f1efb Update remnawave_service.py 2025-08-25 05:03:15 +03:00
Egor 5102286963 Update bot.py 2025-08-25 03:17:27 +03:00
Egor 24c8f25e6e Update subscription.py 2025-08-25 03:16:39 +03:00
Egor de4796ac1c Update menu.py 2025-08-25 03:16:22 +03:00
Egor 95a8da5f19 Update monitoring_service.py 2025-08-25 03:15:45 +03:00
Egor 76f1313550 Update subscription_service.py 2025-08-25 03:15:02 +03:00
Egor 58d22200cf Create subscription_checker.py 2025-08-25 03:14:19 +03:00
Egor eb8b09e658 Update subscription.py 2025-08-25 03:12:46 +03:00
Egor 25cf635b6f Update models.py 2025-08-25 03:12:22 +03:00
Egor a54c1001f9 Update models.py 2025-08-25 03:11:49 +03:00
Egor 6140d7042a Update subscription.py 2025-08-24 23:22:56 +03:00
Egor d0135a2328 Update subscription.py 2025-08-24 23:21:08 +03:00
Egor 1048734548 Update README.md 2025-08-24 18:29:43 +03:00
Egor 62116fd5ba Update README.md 2025-08-24 17:27:52 +03:00
Egor 37a905f056 Update states.py 2025-08-24 16:58:17 +03:00
Egor bce9061401 Update users.py 2025-08-24 16:57:29 +03:00
Egor 118b282a2b Update states.py 2025-08-24 16:52:11 +03:00
Egor 6c250ed36f Update users.py 2025-08-24 16:51:31 +03:00
Egor 3842aeacca Update subscription_service.py 2025-08-24 16:23:04 +03:00
Egor 1d1afbe1da Update remnawave_service.py 2025-08-24 16:21:54 +03:00
Egor d4db55ea66 Update subscription.py 2025-08-24 16:19:32 +03:00
Egor 354964060c Update README.md 2025-08-23 18:25:49 +03:00
Egor 5bc8d8e52c Update README.md 2025-08-23 18:25:08 +03:00
Egor 8f3e8bacc4 Update README.md 2025-08-23 18:24:38 +03:00
Egor 61c94e17a4 Update promocode_service.py 2025-08-23 16:04:54 +03:00
Egor 77c52c1197 Update README.md 2025-08-23 14:38:43 +03:00
Egor c65c703e00 Update README.md 2025-08-23 12:56:42 +03:00
Egor 7560e802cb Update README.md 2025-08-23 12:53:05 +03:00
Egor a5a90a806b Update README.md 2025-08-23 12:35:37 +03:00
Egor aa0a271d14 Update README.md 2025-08-23 12:31:25 +03:00
Egor 5d4e51c862 Update README.md 2025-08-23 12:16:27 +03:00
Egor e6057bb93c Update .env.example 2025-08-23 11:58:48 +03:00
Egor 714bdd0d7c Update inline.py 2025-08-23 11:52:42 +03:00
Egor 41f3eb76c1 Update subscription.py 2025-08-23 11:51:12 +03:00
Egor e9c58b3f76 Update config.py 2025-08-23 11:48:26 +03:00
Egor 3d6976ff6e Update config.py 2025-08-23 11:44:40 +03:00
Egor ee978dff7b Add files via upload 2025-08-23 11:41:20 +03:00
Egor 0bcf5c5f43 Create 3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-08-23 11:31:06 +03:00
Egor 2ceedd6587 Update README.md 2025-08-23 07:41:34 +03:00
Egor 2ded1a4a9e Update README.md 2025-08-23 07:09:06 +03:00
Egor d551a7267f Update README.md 2025-08-23 06:27:24 +03:00
Egor 3ddf6b35c6 Update .env.example 2025-08-23 06:26:37 +03:00
Egor c0d3c70683 Update inline.py 2025-08-23 06:25:15 +03:00
Egor 7267433d99 Update subscription.py 2025-08-23 06:22:41 +03:00
Egor 09f3c4d27f Update config.py 2025-08-23 06:21:43 +03:00
Egor 3083915bc8 Update remnawave_api.py 2025-08-22 15:18:21 +03:00
Egor 5c72c04d27 Update texts.py 2025-08-22 12:25:42 +03:00
Egor 826149da3d Update balance.py 2025-08-22 12:19:14 +03:00
Egor f090be328f Update menu.py 2025-08-22 11:40:57 +03:00
Egor ecbfcd2b54 Update texts.py 2025-08-22 11:40:27 +03:00
Egor 2d21b1c542 Update balance.py 2025-08-22 11:17:22 +03:00
Egor c63438af5e Update monitoring_service.py 2025-08-22 11:11:52 +03:00
Egor 8e47013ae2 Update texts.py 2025-08-22 11:09:54 +03:00
Egor 92fa0ebcc1 Update inline.py 2025-08-22 11:09:19 +03:00
Egor f1d23616f1 Update balance.py 2025-08-22 11:08:37 +03:00
Egor d554f2d801 Create subscription_notifications.py 2025-08-22 11:07:10 +03:00
Egor 1fe4608604 Update monitoring_service.py 2025-08-22 11:05:41 +03:00
Egor bea534b352 Update README.md 2025-08-22 09:17:46 +03:00
Egor e993c19a34 Update README.md 2025-08-22 09:14:40 +03:00
Egor 2deb5d1bc1 Update README.md 2025-08-22 09:06:46 +03:00
Egor fb1b0bbcd5 Update README.md 2025-08-22 09:02:36 +03:00
Egor 0308dd6c58 Update README.md 2025-08-22 09:02:15 +03:00
Egor 860bf7fc7a Update user_service.py 2025-08-22 08:01:34 +03:00
Egor c24024db18 Update user_service.py 2025-08-22 07:50:18 +03:00
Egor 7d330838e0 Update auth.py 2025-08-22 07:48:45 +03:00
Egor feed3fff0d Update start.py 2025-08-22 07:47:36 +03:00
Egor 4ddaafd052 Update requirements.txt 2025-08-22 07:24:08 +03:00
Egor 140194a0ad Update admin.py 2025-08-22 07:21:56 +03:00
Egor 7c6adae87f Update user_service.py 2025-08-22 07:21:19 +03:00
Egor 03deb91a25 Update auth.py 2025-08-22 07:20:20 +03:00
Egor ea2caa98ae Update users.py 2025-08-22 07:19:40 +03:00
Egor dab69b9229 Update bot.py 2025-08-22 07:18:40 +03:00
Egor 0ffa86ff36 Update subscription.py 2025-08-22 02:30:55 +03:00
Egor 3b08b71472 Update inline.py 2025-08-22 02:30:23 +03:00
Egor 6c7b1353df Update subscription.py 2025-08-22 02:29:51 +03:00
Egor 9e009d2ded Update subscription_service.py 2025-08-22 02:29:11 +03:00
Egor dc6a829f0f Update texts.py 2025-08-22 01:12:26 +03:00
Egor 3a3a4aa9aa Update inline.py 2025-08-22 01:05:00 +03:00
Egor 99cd22d76b Update subscription.py 2025-08-22 01:04:42 +03:00
Egor 0a920bffe6 Update remnawave.py 2025-08-22 00:22:37 +03:00
Egor 55fbc65231 Update remnawave_service.py 2025-08-22 00:22:05 +03:00
Egor f508ac151b Update admin.py 2025-08-22 00:21:36 +03:00
Egor abec12eed1 Update subscription.py 2025-08-21 21:33:40 +03:00
Egor 9b50fbbe05 Update inline.py 2025-08-21 21:33:19 +03:00
Egor 9311e7c11d Update config.py 2025-08-21 06:11:53 +03:00
Egor 625972da87 Update inline.py 2025-08-21 06:11:08 +03:00
Egor d3293615b0 Update subscription.py 2025-08-21 06:09:28 +03:00
Egor eef1fcc705 Create app-config.json 2025-08-21 06:06:27 +03:00
Egor ffe8259025 Update README.md 2025-08-21 04:48:28 +03:00
Egor bac42b51e1 Update README.md 2025-08-21 02:46:20 +03:00
Egor 510ec2990d Update README.md 2025-08-21 02:40:21 +03:00
Egor dc06056cc7 Update README.md 2025-08-21 02:37:02 +03:00
Egor 103d6e8854 Update .env.example 2025-08-21 02:34:31 +03:00
Egor 466f1e66cc Update README.md 2025-08-21 02:34:15 +03:00
Egor 5de90b20f6 Merge pull request #11 from Fr1ngg/BEDOLAGANEW
Bedolaganew
2025-08-21 02:03:21 +03:00
Egor 28a26b61ac Update README.md
Update for v2.0.0
2025-08-21 01:45:31 +03:00
Egor 2182b47666 Create Dockerfile 2025-08-21 00:21:49 +03:00
Egor 347c62ec8b Create .env.example 2025-08-21 00:03:02 +03:00
Egor 736e4c6cae NEW VERSION
NEW VERSION
2025-08-20 23:57:04 +03:00
Egor 92a07e2acf UPDATE 2025-08-20 23:54:53 +03:00
Egor 83af139d01 Update README.md 2025-08-19 17:48:07 +03:00
Egor e76e297d22 Update keyboards.py 2025-08-17 05:32:45 +03:00
Egor b317368df2 Update admin_handlers.py 2025-08-17 05:32:30 +03:00
Egor 572170b1cc Update database.py 2025-08-17 05:32:14 +03:00
Egor 1adeb53a0d Update handlers.py 2025-08-17 05:31:58 +03:00
Egor 3e5811ec11 Update handlers.py 2025-08-17 05:25:04 +03:00
Egor e26a76c8cf Update handlers.py 2025-08-17 05:20:31 +03:00
Egor 1d5755e415 Update keyboards.py 2025-08-17 05:16:43 +03:00
Egor 32beec7515 Update database.py 2025-08-17 05:16:31 +03:00
Egor feb2250f37 Update admin_handlers.py 2025-08-17 05:16:06 +03:00
Egor da36db39c5 Update handlers.py 2025-08-17 05:15:50 +03:00
Egor fd0fa71f85 Update subscription_monitor.py 2025-08-17 04:41:07 +03:00
Egor 04250b38bb Update README.md 2025-08-17 00:47:11 +03:00
Egor deb6978572 Update README.md 2025-08-16 17:10:40 +03:00
Egor d2a26ead1e Update .env.example 2025-08-16 13:59:00 +03:00
Egor 9e40f406ee Update config.py 2025-08-16 13:57:26 +03:00
Egor 2cdfaf9356 Update subscription_monitor.py 2025-08-16 13:56:54 +03:00
Egor 66af399bc0 Update .env.example 2025-08-16 13:13:10 +03:00
Egor 5fa69dec3e Update README.md 2025-08-16 09:01:18 +03:00
Egor 3d5f3303d1 Update README.md 2025-08-16 08:58:16 +03:00
Egor 10883e92b1 Update handlers.py 2025-08-16 07:13:07 +03:00
Egor e2d28333ec Update handlers.py 2025-08-16 07:08:45 +03:00
Egor 6d55d4832f Update remnawave_api.py 2025-08-16 06:58:48 +03:00
Egor 46c563873f Update handlers.py 2025-08-16 06:58:29 +03:00
Egor 10c5d3c408 Update remnawave_api.py 2025-08-16 06:53:39 +03:00
Egor 942080c460 Update handlers.py 2025-08-16 06:53:15 +03:00
Egor 6a0869ca0a Update remnawave_api.py 2025-08-16 06:31:15 +03:00
Egor d9992dd2f2 Update remnawave_api.py 2025-08-16 06:30:01 +03:00
Egor c8c9631518 Update remnawave_api.py 2025-08-16 06:29:35 +03:00
Egor 943f29f227 Update handlers.py 2025-08-16 06:13:03 +03:00
Egor 80a069077c Update handlers.py 2025-08-16 06:09:01 +03:00
Egor e344e55e6a Create docker-hub.yml 2025-08-16 02:27:32 +03:00
Egor a69947b69a Delete .github/workflows/docker-build.yml 2025-08-16 02:27:12 +03:00
Egor 926f593f50 Create docker-registry.yml 2025-08-16 02:26:57 +03:00
Egor f13734a4e9 Update translations.py 2025-08-16 02:19:36 +03:00
Egor 2107166926 Update translations.py 2025-08-16 02:18:16 +03:00
Egor 60e5601690 Update README.md 2025-08-16 02:13:48 +03:00
Egor 0957d9ef88 Update bedolaga.sh 2025-08-16 02:10:19 +03:00
Egor eb481c57ec Update keyboards.py 2025-08-16 00:15:29 +03:00
Egor e0b1666f5d Update handlers.py 2025-08-16 00:14:37 +03:00
Egor f2afc088af Update README.md 2025-08-15 22:41:43 +03:00
Egor 971d59b2c6 Update README.md 2025-08-15 22:39:18 +03:00
Egor 83fe58056f Update README.md 2025-08-15 22:38:26 +03:00
Egor 5f107922d0 Update README.md 2025-08-15 22:26:56 +03:00
Egor a2ea6b7bf7 Update README.md 2025-08-15 22:20:23 +03:00
Egor 1e0e728840 Update README.md 2025-08-15 22:19:51 +03:00
Egor cea6ed3f95 Update README.md 2025-08-15 08:54:16 +03:00
Egor f15705ed47 Update README.md 2025-08-15 08:52:34 +03:00
Egor 0b9c13ae4b Update README.md 2025-08-15 08:49:35 +03:00
Egor d0c8ae02f0 Update README.md 2025-08-15 08:47:50 +03:00
Egor 4eda7401b9 Update README.md 2025-08-15 08:44:07 +03:00
Egor 0c2f3c9399 Update README.md 2025-08-15 08:43:01 +03:00
Egor 541ecae48c Update bedolaga.sh 2025-08-15 08:37:48 +03:00
Egor 82a551f1d4 Update README.md 2025-08-15 07:29:29 +03:00
Egor fd2253135d Update README.md 2025-08-15 07:27:51 +03:00
Egor d0820aa47e Update README.md 2025-08-15 07:27:24 +03:00
Egor 09c5800f49 Delete emergency_fix.py 2025-08-15 06:41:25 +03:00
Egor 52aa2dc6a8 Delete Makefile 2025-08-15 06:40:25 +03:00
Egor 6418ee7735 Delete docker-compose.yml 2025-08-15 06:39:39 +03:00
Egor 25aef89b4f Update README.md 2025-08-15 05:35:18 +03:00
Egor 77d17f97d3 Update admin_handlers.py 2025-08-15 04:56:43 +03:00
Egor d5c1ec3aea Update database.py 2025-08-15 04:54:53 +03:00
Egor 97a7384381 Update database.py 2025-08-15 04:49:48 +03:00
Egor 27b7bfccba Update admin_handlers.py 2025-08-15 04:49:18 +03:00
Egor ab15624a93 Update keyboards.py 2025-08-15 04:19:58 +03:00
Egor a9c5d8ed69 Update handlers.py 2025-08-15 04:19:40 +03:00
Egor 234b90796e Update handlers.py 2025-08-15 04:02:38 +03:00
Egor 5f018ba22f Update keyboards.py 2025-08-15 04:00:47 +03:00
Egor e11cbbcc18 Update handlers.py 2025-08-15 03:58:51 +03:00
Egor ba4d30d8db Update handlers.py 2025-08-15 03:46:22 +03:00
Egor d11431769c Update keyboards.py 2025-08-15 03:36:12 +03:00
Egor d1d1bbb3e4 Update handlers.py 2025-08-15 03:35:42 +03:00
Egor 823f30882a Update handlers.py 2025-08-15 03:33:09 +03:00
Egor d36f389469 Merge pull request #1 from maximovnikolay/feature/disable_stars_payment
disable stars payment button by config

Спасибо за помощь и вклад! Очень ценю!
2025-08-15 03:25:01 +03:00
Maximov Nikolay add686a4d5 disable stars payment button by config 2025-08-14 23:49:23 +03:00
Egor 59be2d8f02 Update README.md 2025-08-14 05:47:01 +03:00
Egor aa3db49f65 Update README.md 2025-08-14 05:39:19 +03:00
Egor c101cb3d3f Update README.md 2025-08-14 05:29:41 +03:00
Egor 63912af43b Update README.md 2025-08-14 05:26:12 +03:00
Egor d19263dd79 Update .env.example 2025-08-14 05:08:03 +03:00
Egor f8c9c59a03 Update database.py 2025-08-14 04:45:02 +03:00
Egor 5595f7a954 Update admin_handlers.py 2025-08-14 04:44:33 +03:00
Egor bac4527945 Update autopay_service.py 2025-08-14 04:44:09 +03:00
Egor 51da8bcb72 Update remnawave_api.py 2025-08-14 04:41:38 +03:00
Egor 8aa6e7e245 Update subscription_monitor.py 2025-08-14 04:40:29 +03:00
Egor 5029b35d14 Update subscription_monitor.py 2025-08-14 04:39:37 +03:00
Egor 2412027d9b Update handlers.py 2025-08-14 04:39:17 +03:00
Egor ddff5219e5 Create webhook_server.py 2025-08-14 04:38:58 +03:00
Egor 346bc18b58 Create tribute_service.py 2025-08-14 04:38:12 +03:00
Egor c256ae3664 Update keyboards.py 2025-08-14 04:37:33 +03:00
Egor 2b47358a9e Update config.py 2025-08-14 04:36:58 +03:00
Egor e0b041ce44 Update main.py 2025-08-14 04:36:41 +03:00
Egor 3f64dee927 Update database.py 2025-08-13 08:02:35 +03:00
Egor 67c5ebeabd Update admin_handlers.py 2025-08-13 08:02:18 +03:00
Egor 84bc8e96cc Update database.py 2025-08-13 07:58:18 +03:00
Egor 2d30cec7c6 Update lucky_game.py 2025-08-13 07:55:50 +03:00
Egor 5f5f66178b Update lucky_game.py 2025-08-13 07:50:17 +03:00
Egor e9fadec0ac Update database.py 2025-08-13 07:49:40 +03:00
Egor 10008e51ff Update admin_handlers.py 2025-08-13 07:39:20 +03:00
Egor 3098610d17 Update database.py 2025-08-13 07:31:29 +03:00
Egor 7a2566d320 Update admin_handlers.py 2025-08-13 07:31:09 +03:00
Egor 01ba8ca53e Update subscription_monitor.py 2025-08-13 07:09:12 +03:00
Egor 2a0a2dccff Update README.md 2025-08-13 06:55:22 +03:00
Egor 66a6f32d4c Update .env.example 2025-08-13 06:51:35 +03:00
Egor 02b3f61953 Update translations.py 2025-08-13 06:37:10 +03:00
Egor dc08526255 Update config.py 2025-08-13 06:36:52 +03:00
Egor 2ca339610b Update subscription_monitor.py 2025-08-13 06:36:32 +03:00
Egor ffe65937c1 Update README.md 2025-08-12 19:16:27 +03:00
Egor 4b6f03eef5 Update README.md 2025-08-12 19:15:02 +03:00
Egor d2f530a7fe Update bedolaga.sh 2025-08-12 19:10:37 +03:00
Egor 44ea4b6b13 Update .env.example 2025-08-12 18:55:22 +03:00
Egor ebc8421a70 Update README.md 2025-08-12 18:52:20 +03:00
Egor 526f142dea Update remnawave_api.py 2025-08-12 18:12:34 +03:00
Egor ca431d36e6 Add files via upload 2025-08-12 03:49:13 +03:00
Egor 68a81bdd89 Delete screens/уведомления/Снимок экрана 2025-08-12 в 01.45.14.png 2025-08-12 03:48:59 +03:00
Fr1ngg 68a3d713ef Update README.md 2025-08-12 03:19:42 +03:00
Fr1ngg 50d3d08e38 Update README.md 2025-08-12 03:14:42 +03:00
Fr1ngg 9a2fec4d8f Update README.md 2025-08-12 03:10:34 +03:00
Fr1ngg e68b1d2092 Add files via upload 2025-08-12 02:48:15 +03:00
Fr1ngg 6dfedef81e Update README.md 2025-08-12 02:36:41 +03:00
Fr1ngg 317e39c823 Update keyboards.py 2025-08-12 02:34:19 +03:00
Fr1ngg 0072a3cb71 Update translations.py 2025-08-12 02:34:03 +03:00
Fr1ngg 8d021c1530 Update database.py 2025-08-12 02:33:44 +03:00
Fr1ngg 23606dc2d4 Update admin_handlers.py 2025-08-12 02:33:23 +03:00
Fr1ngg 0838c4196d Add files via upload 2025-08-12 01:47:52 +03:00
Fr1ngg 65605a0de2 Create 1 2025-08-12 01:47:38 +03:00
Fr1ngg a20ac80281 Delete screens/уведомления 2025-08-12 01:47:25 +03:00
Fr1ngg 02e83fb0d0 Create уведомления 2025-08-12 01:47:13 +03:00
Fr1ngg eddad0db6e Add files via upload 2025-08-12 01:46:00 +03:00
Fr1ngg ffad0afb61 Delete screens/Balance/Снимок экрана 2025-08-09 в 07.54.52.png 2025-08-12 01:44:40 +03:00
Fr1ngg 83eed8e1cd Add files via upload 2025-08-12 01:44:17 +03:00
Fr1ngg 0c7c8abade Add files via upload 2025-08-12 01:42:18 +03:00
Fr1ngg a064890ed8 Delete screens/adminpanel/Снимок экрана 2025-08-09 в 08.09.05.png 2025-08-12 01:41:33 +03:00
Fr1ngg f310f4a35e Delete screens/sub/Снимок экрана 2025-08-09 в 08.05.47.png 2025-08-12 01:41:14 +03:00
Fr1ngg 0268e09882 Delete screens/Главное меню/Mainmenu.png 2025-08-12 01:40:23 +03:00
Fr1ngg 449814d0df Add files via upload 2025-08-12 01:40:09 +03:00
Fr1ngg a3d3577103 Update README.md 2025-08-12 01:37:43 +03:00
Fr1ngg a3086fdd2c Update README.md 2025-08-12 01:34:57 +03:00
Fr1ngg daafc6a41e Update README.md 2025-08-12 01:31:42 +03:00
Fr1ngg c27ee116f7 Update README.md 2025-08-12 01:28:56 +03:00
Fr1ngg 5167cde443 Update bedolaga.sh 2025-08-12 01:25:30 +03:00
Fr1ngg 34542d3a9c Update bedolaga.sh 2025-08-12 01:09:56 +03:00
Fr1ngg e73d1df4cc Update logo2.svg 2025-08-12 01:05:39 +03:00
Fr1ngg f9a95c3d48 Update README.md 2025-08-12 01:04:53 +03:00
Fr1ngg 2dd2b5fdbf Update README.md 2025-08-12 01:04:37 +03:00
Fr1ngg 896f1db9d4 Update README.md 2025-08-11 23:09:23 +03:00
Fr1ngg a1bd2d775f Create emergency_fix.py 2025-08-11 22:54:05 +03:00
Fr1ngg 8cbe23f9ed Update database.py 2025-08-11 22:31:17 +03:00
Fr1ngg edc891c93d Update README.md 2025-08-11 22:06:49 +03:00
Fr1ngg 2d706a4be0 Update keyboards.py 2025-08-11 22:01:38 +03:00
Fr1ngg 32cb81ef34 Update handlers.py 2025-08-11 22:01:09 +03:00
Fr1ngg 88aa3c4a3d Update database.py
autopay mod
2025-08-11 22:00:43 +03:00
Fr1ngg e813d3ccb7 Create autopay_service.py 2025-08-11 22:00:19 +03:00
Fr1ngg a24d7a4e24 Update main.py 2025-08-11 21:59:51 +03:00
Fr1ngg 39987b9717 Update admin_handlers.py
autopay mod
2025-08-11 21:59:31 +03:00
Fr1ngg c77a1ea4f8 Update README.md 2025-08-11 17:57:35 +03:00
Fr1ngg 64b5d90d66 Update README.md 2025-08-11 03:00:54 +03:00
Fr1ngg 5a1fb32e1d Update admin_handlers.py 2025-08-11 02:58:19 +03:00
Fr1ngg c4d8108e8c Update config.py 2025-08-11 02:53:37 +03:00
Fr1ngg 1350d80083 Update database.py 2025-08-11 02:51:28 +03:00
Fr1ngg e0cc218007 Update handlers.py 2025-08-11 02:49:06 +03:00
Fr1ngg bcaf8f2247 Update keyboards.py 2025-08-11 02:48:36 +03:00
Fr1ngg d1190ed9dc Update stars_handlers.py 2025-08-11 02:47:05 +03:00
Fr1ngg 513d6037d4 Update subscription_monitor.py 2025-08-11 02:46:40 +03:00
Fr1ngg e86012b2ab Update translations.py 2025-08-11 02:46:11 +03:00
Fr1ngg 214e381dc5 Update README.md 2025-08-10 10:41:22 +03:00
Fr1ngg aa79cb670b Update .env.example 2025-08-10 10:30:33 +03:00
Fr1ngg f0106e15fa Create stars_handlers.py 2025-08-10 10:26:28 +03:00
Fr1ngg 1d194c6be2 Update .env.example 2025-08-10 10:18:03 +03:00
Fr1ngg 6917890007 Update admin_handlers.py 2025-08-10 10:15:34 +03:00
Fr1ngg 1c69de7ecd Update config.py 2025-08-10 10:13:14 +03:00
Fr1ngg 62188793d9 Update database.py 2025-08-10 10:12:21 +03:00
Fr1ngg 585ee2d42b Update handlers.py 2025-08-10 10:11:12 +03:00
Fr1ngg 2a58cfd45c Update keyboards.py 2025-08-10 10:10:54 +03:00
Fr1ngg 93003c5fe0 Update main.py 2025-08-10 10:10:27 +03:00
Fr1ngg 91b0787d72 Update README.md 2025-08-10 07:04:17 +03:00
Fr1ngg 315277d4b0 Create bedolaga.sh 2025-08-10 06:56:04 +03:00
Fr1ngg 1821c0d89d Delete run.sh 2025-08-10 06:54:40 +03:00
Fr1ngg 5650da0c5f Update README.md 2025-08-10 03:56:42 +03:00
Fr1ngg 0ca8b73cef Update .env.example 2025-08-10 03:51:10 +03:00
Fr1ngg 6bab26ab27 Update lucky_game.py 2025-08-10 03:44:57 +03:00
Fr1ngg a72243e7fa Update keyboards.py 2025-08-10 03:42:56 +03:00
Fr1ngg 5bd86d9cf7 Update config.py 2025-08-10 03:40:36 +03:00
Fr1ngg 00fd3fbadb Update database.py 2025-08-10 03:39:51 +03:00
Fr1ngg b806f40797 Update middlewares.py 2025-08-10 03:38:20 +03:00
Fr1ngg c82aa1d253 Update main.py 2025-08-10 03:37:51 +03:00
Fr1ngg 071919f0db Update remnawave_api.py 2025-08-10 03:36:51 +03:00
Fr1ngg ab41e96ba5 Update subscription_monitor.py 2025-08-10 03:34:52 +03:00
Fr1ngg 95a470ba7b Update admin_handlers.py 2025-08-10 03:32:41 +03:00
Fr1ngg 1e0653ed33 Update referral_utils.py 2025-08-10 03:27:15 +03:00
Fr1ngg 3f0df08031 Update handlers.py 2025-08-10 03:25:53 +03:00
Fr1ngg 8c3bc8a5ba Update utils.py 2025-08-10 03:22:42 +03:00
Fr1ngg 3a5a8f48e4 Update README.md 2025-08-09 11:31:02 +03:00
Fr1ngg f4b99ffb09 Rename logo.svg to logo2.svg 2025-08-09 11:30:57 +03:00
Fr1ngg 0c6afdebc3 Update README.md 2025-08-09 11:30:17 +03:00
Fr1ngg 98b382ba2f Update logo.svg 2025-08-09 11:29:57 +03:00
Fr1ngg 54a24385b1 Update README.md 2025-08-09 08:54:57 +03:00
Fr1ngg 3eeb84cdae Update README.md 2025-08-09 08:29:02 +03:00
Fr1ngg 204a10ae14 Update README.md 2025-08-09 08:26:02 +03:00
Fr1ngg ba1dd7547e Create logo.svg 2025-08-09 08:24:25 +03:00
Fr1ngg 238585c257 Update .gitignore 2025-08-09 08:24:02 +03:00
Fr1ngg d8ebe1ad13 Add files via upload 2025-08-09 08:18:01 +03:00
Fr1ngg 819df3e93a Create 1 2025-08-09 08:13:36 +03:00
Fr1ngg 99e46feafa Delete screens/sub/1 2025-08-09 08:07:05 +03:00
Fr1ngg a432c8ab54 Add files via upload 2025-08-09 08:06:52 +03:00
Fr1ngg d740b7560c Create 1 2025-08-09 08:06:17 +03:00
Fr1ngg 91c4c73d7e Delete screens/Balance/1 2025-08-09 08:05:33 +03:00
Fr1ngg 4ce2384f27 Update handlers.py 2025-08-09 08:04:43 +03:00
Fr1ngg 02a6289d56 Add files via upload 2025-08-09 07:57:18 +03:00
Fr1ngg 52ae6eba81 Create 1 2025-08-09 07:55:29 +03:00
Fr1ngg 080b6e74a2 Add files via upload 2025-08-09 07:54:08 +03:00
Fr1ngg ad7d32023c Delete screens/Luckgame/1 2025-08-09 07:53:17 +03:00
Fr1ngg 1baf8fbe37 Add files via upload 2025-08-09 07:53:03 +03:00
Fr1ngg 758ca6a373 Create 1 2025-08-09 07:52:38 +03:00
Fr1ngg 65504d0a23 Delete screens/1 2025-08-09 07:50:15 +03:00
Fr1ngg 0409f4e2e2 Delete screens/Рефералы/1 2025-08-09 07:49:57 +03:00
Fr1ngg 8ca63e939a Add files via upload 2025-08-09 07:49:38 +03:00
Fr1ngg 25a621fd51 Delete screens/Главное меню/1 2025-08-09 07:43:58 +03:00
Fr1ngg 3f71a8aff7 Rename Снимок экрана 2025-08-09 в 07.42.12.png to Mainmenu.png 2025-08-09 07:43:45 +03:00
Fr1ngg 6ec81c37b9 Add files via upload 2025-08-09 07:42:41 +03:00
Fr1ngg 8a9ff98da8 Create 1 2025-08-09 07:40:31 +03:00
Fr1ngg bb7e29eeac Create 1 2025-08-09 07:40:05 +03:00
Fr1ngg 27d8783104 Create 1 2025-08-09 07:39:34 +03:00
Fr1ngg 9a01e6db43 Create .gitignore 2025-08-09 07:37:50 +03:00
Fr1ngg b341ddf9d9 Update README.md 2025-08-09 07:33:26 +03:00
Fr1ngg 3c8d0ac859 Update README.md 2025-08-09 07:33:03 +03:00
Fr1ngg 3f529c0dee Update README.md 2025-08-09 07:19:13 +03:00
Fr1ngg a6734e542d Create lucky_game.py 2025-08-09 07:04:20 +03:00
Fr1ngg 2287a67dfd Create referral_utils.py 2025-08-09 07:03:30 +03:00
Fr1ngg 31dbaa80d7 Update admin_handlers.py 2025-08-09 07:02:43 +03:00
Fr1ngg b1846a3ea9 Update api_error_handlers.py 2025-08-09 07:02:24 +03:00
Fr1ngg e1a7e9e622 Update config.py 2025-08-09 07:02:00 +03:00
Fr1ngg 0ae48faedb Update database.py 2025-08-09 07:01:35 +03:00
Fr1ngg b8122222f8 Update handlers.py 2025-08-09 07:00:51 +03:00
Fr1ngg 5e5a39c22e Update keyboards.py 2025-08-09 07:00:27 +03:00
Fr1ngg 1e7cf214e9 Update main.py 2025-08-09 07:00:10 +03:00
Fr1ngg d7e7a2df9a Update middlewares.py 2025-08-09 06:59:52 +03:00
Fr1ngg 584aa35894 Update remnawave_api.py 2025-08-09 06:59:36 +03:00
Fr1ngg 652ae8057a Update subscription_monitor.py 2025-08-09 06:59:10 +03:00
Fr1ngg 0bc0a1354a Update translations.py 2025-08-09 06:58:45 +03:00
Fr1ngg 7d30838813 Update utils.py 2025-08-09 06:58:22 +03:00
Fr1ngg 3da178ed02 Update handlers.py 2025-08-09 06:58:00 +03:00
Fr1ngg e6ac3e3812 Update README.md 2025-08-09 06:53:32 +03:00
Fr1ngg 62087e20b5 Update README.md 2025-08-09 06:50:45 +03:00
Fr1ngg b7a47c6eb6 Update .env.example 2025-08-09 06:32:47 +03:00
Fr1ngg 9b14baca51 Update README.md 2025-08-08 05:43:08 +03:00
Fr1ngg eb1a75c9f5 Update .env.example 2025-08-08 05:24:58 +03:00
Fr1ngg 5e79106134 Update admin_handlers.py 2025-08-08 05:22:43 +03:00
Fr1ngg ee45ec356e Update api_error_handlers.py 2025-08-08 05:22:20 +03:00
Fr1ngg 9275bff828 Update config.py 2025-08-08 05:21:51 +03:00
Fr1ngg 9150c70618 Update database.py 2025-08-08 05:21:25 +03:00
Fr1ngg b792e749d6 Update handlers.py 2025-08-08 05:21:00 +03:00
Fr1ngg 9871572740 Update keyboards.py 2025-08-08 05:20:37 +03:00
Fr1ngg 52c1cb3cbc Update main.py 2025-08-08 05:20:16 +03:00
Fr1ngg d5b2b371c8 Update middlewares.py 2025-08-08 05:19:23 +03:00
Fr1ngg d5cb022b02 Update remnawave_api.py 2025-08-08 05:19:06 +03:00
Fr1ngg 021f62fd30 Update subscription_monitor.py 2025-08-08 05:18:41 +03:00
Fr1ngg 684e398798 Update translations.py 2025-08-08 05:18:12 +03:00
Fr1ngg 12b64e9978 Update utils.py 2025-08-08 05:17:45 +03:00
Fr1ngg 273df23f20 Update README.md 2025-08-07 09:32:39 +03:00
Fr1ngg a9b0dbf3eb Update handlers.py 2025-08-07 09:31:28 +03:00
Fr1ngg 5f56e068d3 Update remnawave_api.py 2025-08-07 09:27:59 +03:00
Fr1ngg 92b82132d8 Update remnawave_api.py 2025-08-07 09:27:42 +03:00
Fr1ngg 79ae9d3a1c Update config.py 2025-08-07 09:25:55 +03:00
Fr1ngg 86714a93f9 Update handlers.py 2025-08-07 09:25:40 +03:00
Fr1ngg 553f6f8cf6 Update handlers.py 2025-08-07 09:24:22 +03:00
Fr1ngg b561f67c12 Update utils.py 2025-08-07 09:23:52 +03:00
Fr1ngg d03f9b80c7 Update translations.py 2025-08-07 09:23:26 +03:00
129 changed files with 31527 additions and 11543 deletions
+200 -20
View File
@@ -1,29 +1,209 @@
# Bot Configuration
# ===============================================
# 🤖 REMNAWAVE BEDOLAGA BOT CONFIGURATION
# ===============================================
# ===== TELEGRAM BOT =====
BOT_TOKEN=
ADMIN_IDS=
SUPPORT_USERNAME=@support
# RemnaWave API Configuration
REMNAWAVE_URL=https://example.com
REMNAWAVE_MODE=remote
REMNAWAVE_TOKEN=
SUBSCRIPTION_BASE_URL=https://example.com
# ===== 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 Configuration (comma-separated list of Telegram user IDs)
ADMIN_IDS=123445567,12342123
REDIS_URL=redis://redis:6379/0
# Support Configuration
DEFAULT_LANGUAGE=ru
SUPPORT_USERNAME=support
# Пароли для Docker (PostgreSQL/Redis)
POSTGRES_DB=remnawave_bot
POSTGRES_USER=remnawave_user
POSTGRES_PASSWORD=secure_password_123
# Database Configuration (optional, defaults to SQLite)
# DATABASE_URL=sqlite+aiosqlite:///bot.db
# ===== 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_ENABLED=true
# ========= ПОДПИСКИ =========
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_GB=2
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
TRIAL_SQUAD_UUID=
TRIAL_PRICE=0.0
# Monitor Service Settings (дополнительные настройки)
MONITOR_CHECK_INTERVAL=3600
MONITOR_DAILY_CHECK_HOUR=10
MONITOR_WARNING_DAYS=2
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
DEFAULT_DEVICE_LIMIT=3
# Максимум устройств достопных к покупке (0 = Нет лимита)
MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
DEFAULT_TRAFFIC_LIMIT_GB=100
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
# "selectable" - пользователи выбирают пакеты трафика (по умолчанию)
# "fixed" - фиксированный лимит трафика для всех подписок
TRAFFIC_SELECTION_MODE=selectable
# Фиксированный лимит трафика в ГБ (используется только в режиме "fixed")
# 0 = безлимит
FIXED_TRAFFIC_LIMIT_GB=100
# ===== ПЕРИОДЫ ПОДПИСКИ =====
# Доступные периоды подписки (через запятую)
# Возможные значения: 14,30,60,90,180,360
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== ЦЕНЫ (в копейках) =====
BASE_SUBSCRIPTION_PRICE=0
# Цены за периоды
PRICE_14_DAYS=7000
PRICE_30_DAYS=9900
PRICE_60_DAYS=25900
PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Выводимые пакеты трафика и их цены в копейках
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_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# ===== АВТОПРОДЛЕНИЕ =====
AUTOPAY_WARNING_DAYS=3,1
DEFAULT_AUTOPAY_DAYS_BEFORE=3
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
# YooKassa (https://yookassa.ru)
YOOKASSA_ENABLED=false
YOOKASSA_SHOP_ID=
YOOKASSA_SECRET_KEY=
YOOKASSA_RETURN_URL=
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
# 1 - НДС не облагается
# 2 - НДС 0%
# 3 - НДС 10%
# 4 - НДС 20%
# 5 - НДС 10/110
# 6 - НДС 20/120
YOOKASSA_PAYMENT_MODE=full_payment
# Способы расчета:
# full_payment - полная оплата
# partial_payment - частичная оплата
# advance - аванс
# full_prepayment - полная предоплата
# partial_prepayment - частичная предоплата
# credit - передача в кредит
# credit_payment - оплата кредита
YOOKASSA_PAYMENT_SUBJECT=service
# Предметы расчета:
# commodity - товар
# excise - подакцизный товар
# job - работа
# service - услуга
# gambling_bet - ставка в азартной игре
# gambling_prize - выигрыш в азартной игре
# lottery - лотерейный билет
# lottery_prize - выигрыш в лотерее
# intellectual_activity - результат интеллектуальной деятельности
# payment - платеж
# agent_commission - агентское вознаграждение
# composite - составной предмет расчета
# another - другое
# Webhook настройки
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
PAYMENT_SERVICE_NAME=Интернет-сервис
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
PAYMENT_SUBSCRIPTION_DESCRIPTION=Оплата подписки
PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
# ===== ИНТЕРФЕЙС И UX =====
# Режим работы кнопки "Подключиться"
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
MONITORING_INTERVAL=60
INACTIVE_USER_DELETE_MONTHS=3
# Уведомления
TRIAL_WARNING_HOURS=2
ENABLE_NOTIFICATIONS=true
NOTIFICATION_RETRY_ATTEMPTS=3
MONITORING_LOGS_RETENTION_DAYS=30
NOTIFICATION_CACHE_HOURS=24
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# ===== ЛОГИРОВАНИЕ =====
LOG_LEVEL=INFO
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
@@ -1,31 +1,28 @@
name: BedolagaBot
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v2
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@v4
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
cache-from: type=gha
cache-to: type=gha,mode=max
+69
View File
@@ -0,0 +1,69 @@
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
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
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' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate security report
uses: docker/scout-action@v1
if: github.event_name == 'pull_request'
with:
command: quickview,compare
image: ${{ steps.meta.outputs.tags }}
to: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
ignore-unchanged: true
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
+30
View File
@@ -0,0 +1,30 @@
# Игнорируем все файлы и папки по умолчанию
*
# Исключения: разрешаем только нужные файлы
!.env.example
!Dockerfile
!app-config.json
!main.py
!requirements.txt
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
!LICENSE
# Разрешаем .gitignore чтобы он попал в репозиторий
!.gitignore
# Внутри разрешенных папок игнорируем служебные файлы
app/__pycache__/
app/**/__pycache__/
app/**/*.pyc
app/**/*.pyo
app/**/*.pyd
*.pyc
*.pyo
*.pyd
+22 -25
View File
@@ -1,46 +1,43 @@
# Use Python 3.11 slim image
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libpq-dev \
curl \
&& apt-get clean \
wget \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -r botuser && useradd -r -g botuser botuser
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install Python dependencies
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
pip install --no-cache-dir -r requirements.txt --prefer-binary
# 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 . .
# Create necessary directories
RUN mkdir -p /app/logs /app/data && \
chown -R botuser:botuser /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
# Switch to non-root user
USER botuser
USER app
# Set environment variables
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
# Expose webhook ports
EXPOSE 8081
EXPOSE 8082
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python -c "import asyncio; import sys; sys.exit(0)"
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
# Default command
CMD ["python3", "main.py"]
# Run the application
CMD ["python", "main.py"]
-96
View File
@@ -1,96 +0,0 @@
# RemnaWave Bot Docker Management
.PHONY: help build up down restart logs clean db-backup db-restore
# Default target
help:
@echo "Available commands:"
@echo " build - Build all Docker images"
@echo " up - Start all services"
@echo " up-min - Start only bot and database (minimal setup)"
@echo " up-full - Start all services including nginx and redis"
@echo " down - Stop all services"
@echo " restart - Restart all services"
@echo " logs - Show logs for all services"
@echo " logs-bot - Show logs for bot service only"
@echo " logs-db - Show logs for database service only"
@echo " clean - Remove all containers, networks, and volumes"
@echo " db-backup - Create database backup"
@echo " db-restore - Restore database from backup"
@echo " shell-bot - Open shell in bot container"
@echo " shell-db - Open shell in database container"
# Build all images
build:
docker compose build
# Start minimal services (bot + database)
up-min: setup-dirs
docker compose up -d postgres bot
# Start all services including optional ones
up-full: setup-dirs
docker compose --profile with-nginx up -d
# Start main services (default)
up: setup-dirs
docker compose up -d postgres redis bot
# Setup required directories
setup-dirs:
mkdir -p logs data backups
# Stop all services
down:
docker compose down
# Restart all services
restart:
docker compose restart
# Show logs for all services
logs:
docker compose logs -f
# Show logs for bot only
logs-bot:
docker compose logs -f bot
# Show logs for database only
logs-db:
docker compose logs -f postgres
# Clean up everything (DANGEROUS - removes all data)
clean:
@echo "This will remove all containers, networks, and volumes. Are you sure? [y/N]"
@read answer && [ "$$answer" = "y" ] || [ "$$answer" = "Y" ]
docker compose down -v --remove-orphans
docker system prune -f
# Database backup
db-backup:
@mkdir -p backups
docker compose exec postgres pg_dump -U remnawave_user remnawave_bot > backups/backup_$(shell date +%Y%m%d_%H%M%S).sql
@echo "Backup created in backups/ directory"
# Database restore (use: make db-restore BACKUP=backup_20231201_120000.sql)
db-restore:
@if [ -z "$(BACKUP)" ]; then echo "Usage: make db-restore BACKUP=backup_file.sql"; exit 1; fi
docker compose exec -T postgres psql -U remnawave_user -d remnawave_bot < backups/$(BACKUP)
@echo "Database restored from $(BACKUP)"
# Open shell in bot container
shell-bot:
docker compose exec bot /bin/bash
# Open shell in database container
shell-db:
docker compose exec postgres psql -U remnawave_user -d remnawave_bot
# Check services status
status:
docker compose ps
# View service resource usage
stats:
docker stats --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
+934 -157
View File
File diff suppressed because it is too large Load Diff
-5640
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-233
View File
@@ -1,233 +0,0 @@
# api_error_handlers.py - Дополнительные утилиты для обработки ошибок API
import logging
from typing import Optional, Dict, Any, Callable
from aiogram.types import CallbackQuery
from aiogram import Router
from remnawave_api import RemnaWaveAPI
from translations import t
logger = logging.getLogger(__name__)
class APIErrorHandler:
"""Класс для обработки ошибок API и предоставления пользователю понятной информации"""
@staticmethod
async def handle_api_error(callback: CallbackQuery, error: Exception,
operation: str, user_language: str = 'ru',
fallback_keyboard=None) -> bool:
"""
Обработка ошибок API с отправкой понятного сообщения пользователю
Returns:
bool: True если ошибка была обработана, False если нужно перепробросить
"""
error_message = str(error).lower()
if "timeout" in error_message or "connection" in error_message:
text = "⏱ Таймаут подключения к API\n\n"
text += "Возможные причины:\n"
text += "• Медленный интернет\n"
text += "• Перегрузка сервера RemnaWave\n"
text += "• Временные проблемы с сетью\n\n"
text += "🔄 Попробуйте повторить операцию через несколько секунд"
elif "401" in error_message or "unauthorized" in error_message:
text = "🔐 Ошибка авторизации API\n\n"
text += "Токен доступа недействителен или истек.\n"
text += "Обратитесь к администратору для обновления токена."
elif "404" in error_message or "not found" in error_message:
text = f"❌ Ресурс не найден\n\n"
text += f"Операция: {operation}\n"
text += "Возможно, запрашиваемый объект был удален или не существует."
elif "500" in error_message or "internal server error" in error_message:
text = "🔥 Внутренняя ошибка сервера RemnaWave\n\n"
text += "Сервер временно недоступен.\n"
text += "Попробуйте повторить операцию позже."
else:
text = f"❌ Ошибка API операции: {operation}\n\n"
text += f"Детали: {str(error)[:100]}{'...' if len(str(error)) > 100 else ''}\n\n"
text += "Обратитесь к администратору если проблема повторяется."
try:
await callback.message.edit_text(
text,
reply_markup=fallback_keyboard or error_recovery_keyboard(operation, user_language)
)
return True
except Exception as edit_error:
logger.error(f"Failed to edit message with error info: {edit_error}")
try:
await callback.answer(f"❌ Ошибка: {operation}", show_alert=True)
return True
except:
return False
@staticmethod
async def safe_api_call(api_method: Callable, *args, **kwargs) -> tuple[bool, Any]:
"""
Безопасный вызов метода API с обработкой ошибок
Returns:
tuple: (success: bool, result: Any)
"""
try:
result = await api_method(*args, **kwargs)
return True, result
except Exception as e:
logger.error(f"API call failed: {api_method.__name__} - {e}")
return False, str(e)
# Дополнительные обработчики для исправления конкретных проблем
def create_error_recovery_keyboard(error_context: str, language: str = 'ru'):
"""Создание клавиатуры для восстановления после ошибки"""
from keyboards import error_recovery_keyboard
return error_recovery_keyboard(error_context, language)
# Улучшенные функции для работы с RemnaWave API
async def safe_get_nodes(api: RemnaWaveAPI) -> tuple[bool, list]:
"""Безопасное получение списка нод"""
try:
logger.info("Attempting to fetch nodes from API...")
nodes = await api.get_all_nodes()
if nodes is None:
logger.warning("API returned None for nodes")
return False, []
if not isinstance(nodes, list):
logger.warning(f"API returned non-list for nodes: {type(nodes)}")
return False, []
logger.info(f"Successfully fetched {len(nodes)} nodes")
return True, nodes
except Exception as e:
logger.error(f"Error fetching nodes: {e}")
return False, []
async def safe_get_system_users(api: RemnaWaveAPI) -> tuple[bool, list]:
"""Безопасное получение списка пользователей системы"""
try:
logger.info("Attempting to fetch system users from API...")
users = await api.get_all_system_users_full()
if users is None:
logger.warning("API returned None for users")
return False, []
if not isinstance(users, list):
logger.warning(f"API returned non-list for users: {type(users)}")
return False, []
logger.info(f"Successfully fetched {len(users)} users")
return True, users
except Exception as e:
logger.error(f"Error fetching system users: {e}")
return False, []
async def safe_restart_nodes(api: RemnaWaveAPI, all_nodes: bool = True, node_id: str = None) -> tuple[bool, str]:
"""Безопасная перезагрузка нод"""
try:
if all_nodes:
logger.info("Attempting to restart all nodes...")
result = await api.restart_all_nodes()
else:
logger.info(f"Attempting to restart node {node_id}...")
result = await api.restart_node(node_id)
if result:
message = "Команда перезагрузки отправлена успешно"
logger.info(f"Restart command sent successfully")
return True, message
else:
message = "API вернул отрицательный результат"
logger.warning("API returned negative result for restart")
return False, message
except Exception as e:
logger.error(f"Error restarting nodes: {e}")
return False, str(e)
# Функции для проверки состояния API
async def check_api_health(api: RemnaWaveAPI) -> Dict[str, Any]:
"""Проверка состояния API"""
health_info = {
'api_available': False,
'nodes_accessible': False,
'users_accessible': False,
'system_stats_accessible': False,
'errors': []
}
if api is None:
health_info['errors'].append("API instance is None")
return health_info
# Проверяем доступность API
try:
# Простая проверка через получение нод (обычно быстрая операция)
success, nodes = await safe_get_nodes(api)
if success:
health_info['api_available'] = True
health_info['nodes_accessible'] = True
else:
health_info['errors'].append("Cannot fetch nodes")
except Exception as e:
health_info['errors'].append(f"Nodes check failed: {e}")
# Проверяем доступность пользователей
try:
success, users = await safe_get_system_users(api)
if success:
health_info['users_accessible'] = True
else:
health_info['errors'].append("Cannot fetch users")
except Exception as e:
health_info['errors'].append(f"Users check failed: {e}")
# Проверяем системную статистику
try:
stats = await api.get_system_stats()
if stats:
health_info['system_stats_accessible'] = True
else:
health_info['errors'].append("Cannot fetch system stats")
except Exception as e:
health_info['errors'].append(f"System stats check failed: {e}")
return health_info
# Декоратор для автоматической обработки ошибок API
def handle_api_errors(operation_name: str):
"""Декоратор для автоматической обработки ошибок API в handler'ах"""
def decorator(func):
async def wrapper(callback: CallbackQuery, user, *args, **kwargs):
try:
return await func(callback, user, *args, **kwargs)
except Exception as e:
logger.error(f"Error in {func.__name__}: {e}")
# Получаем API из kwargs если есть
api = kwargs.get('api')
fallback_keyboard = None
# Создаем fallback клавиатуру в зависимости от операции
if 'nodes' in operation_name.lower():
from keyboards import admin_system_keyboard
fallback_keyboard = admin_system_keyboard(user.language)
elif 'users' in operation_name.lower():
from keyboards import system_users_keyboard
fallback_keyboard = system_users_keyboard(user.language)
# Обрабатываем ошибку
await APIErrorHandler.handle_api_error(
callback, e, operation_name, user.language, fallback_keyboard
)
return wrapper
return decorator
+163
View File
@@ -0,0 +1,163 @@
{
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "App Store [EU]",
"ru": "App Store [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "App Store [RU]",
"ru": "App Store [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов."
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"ru": "Скачать APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Откройте приложение и подключитесь к серверу"
}
}
}
],
"pc": [
{
"id": "happ",
"name": "Happ",
"isFeatured": false,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
"buttonText": {
"en": "Mac Os",
"ru": "Mac Os"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
"buttonText": {
"en": "Windows",
"ru": "Windows"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server",
"ru": "Выберете локацию, включить VPN"
}
}
}
],
"tv": [
{
"id": "vpn4tv",
"name": "VPN4TV",
"isFeatured": true,
"urlScheme": "",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
}
}
}
]
}
+128
View File
@@ -0,0 +1,128 @@
import logging
from aiogram import Bot, Dispatcher, types
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.fsm.storage.memory import MemoryStorage
import redis.asyncio as redis
from app.config import settings
from app.middlewares.auth import AuthMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.throttling import ThrottlingMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, common
)
from app.handlers.admin import (
main as admin_main, users as admin_users, subscriptions as admin_subscriptions,
promocodes as admin_promocodes, messages as admin_messages,
monitoring as admin_monitoring, referrals as admin_referrals,
rules as admin_rules, remnawave as admin_remnawave,
statistics as admin_statistics, servers as admin_servers,
maintenance as admin_maintenance
)
from app.handlers.stars_payments import register_stars_handlers
logger = logging.getLogger(__name__)
async def debug_callback_handler(callback: types.CallbackQuery):
logger.info(f"🔍 DEBUG CALLBACK:")
logger.info(f" - Data: {callback.data}")
logger.info(f" - User: {callback.from_user.id}")
logger.info(f" - Username: {callback.from_user.username}")
async def setup_bot() -> tuple[Bot, Dispatcher]:
try:
await cache.connect()
logger.info("Кеш инициализирован")
except Exception as e:
logger.warning(f"Кеш не инициализирован: {e}")
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
maintenance_service.set_bot(bot)
logger.info("Бот установлен в maintenance_service")
try:
redis_client = redis.from_url(settings.REDIS_URL)
await redis_client.ping()
storage = RedisStorage(redis_client)
logger.info("Подключено к Redis для FSM storage")
except Exception as e:
logger.warning(f"Не удалось подключиться к Redis: {e}")
logger.info("Используется MemoryStorage для FSM")
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)
balance.register_handlers(dp)
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)
admin_servers.register_handlers(dp)
admin_promocodes.register_handlers(dp)
admin_messages.register_handlers(dp)
admin_monitoring.register_handlers(dp)
admin_referrals.register_handlers(dp)
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_maintenance.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
logger.info("Бот успешно настроен")
return bot, dp
async def shutdown_bot():
try:
await maintenance_service.stop_monitoring()
logger.info("Мониторинг техработ остановлен")
except Exception as e:
logger.error(f"Ошибка остановки мониторинга: {e}")
try:
await cache.close()
logger.info("Соединения с кешем закрыты")
except Exception as e:
logger.error(f"Ошибка закрытия кеша: {e}")
+468
View File
@@ -0,0 +1,468 @@
import os
from typing import List, Optional, Union, Dict
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
from pathlib import Path
class Settings(BaseSettings):
BOT_TOKEN: str
ADMIN_IDS: str = ""
SUPPORT_USERNAME: str = "@support"
DATABASE_URL: str
REDIS_URL: str = "redis://localhost:6379/0"
REMNAWAVE_API_URL: str
REMNAWAVE_API_KEY: str
REMNAWAVE_SECRET_KEY: Optional[str] = None
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
TRIAL_DEVICE_LIMIT: int = 2
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
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
NOTIFICATION_RETRY_ATTEMPTS: int = 3
MONITORING_LOGS_RETENTION_DAYS: int = 30
NOTIFICATION_CACHE_HOURS: int = 24
BASE_SUBSCRIPTION_PRICE: int = 50000
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
AVAILABLE_RENEWAL_PERIODS: str = "30,90,180"
PRICE_14_DAYS: int = 50000
PRICE_30_DAYS: int = 99000
PRICE_60_DAYS: int = 189000
PRICE_90_DAYS: int = 269000
PRICE_180_DAYS: int = 499000
PRICE_360_DAYS: int = 899000
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_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"
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
MONITORING_INTERVAL: int = 60
INACTIVE_USER_DELETE_MONTHS: int = 3
MAINTENANCE_MODE: bool = False
MAINTENANCE_CHECK_INTERVAL: int = 30
MAINTENANCE_AUTO_ENABLE: bool = True
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
YOOKASSA_ENABLED: bool = False
YOOKASSA_SHOP_ID: Optional[str] = None
YOOKASSA_SECRET_KEY: Optional[str] = None
YOOKASSA_RETURN_URL: Optional[str] = None
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_SUBJECT: str = "service"
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
PAYMENT_BALANCE_TEMPLATE: str = "{service_name} - {description}"
PAYMENT_SUBSCRIPTION_TEMPLATE: str = "{service_name} - {description}"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/bot.log"
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
APP_CONFIG_PATH: str = "app-config.json"
ENABLE_DEEP_LINKS: bool = True
APP_CONFIG_CACHE_TTL: int = 3600
@field_validator('LOG_FILE', mode='before')
@classmethod
def ensure_log_dir(cls, v):
log_path = Path(v)
log_path.parent.mkdir(parents=True, exist_ok=True)
return str(log_path)
def is_admin(self, user_id: int) -> bool:
return user_id in self.get_admin_ids()
def get_admin_ids(self) -> List[int]:
try:
admin_ids = self.ADMIN_IDS
if isinstance(admin_ids, str):
if not admin_ids.strip():
return []
return [int(x.strip()) for x in admin_ids.split(',') if x.strip()]
return []
except (ValueError, AttributeError):
return []
def get_autopay_warning_days(self) -> List[int]:
try:
days = self.AUTOPAY_WARNING_DAYS
if isinstance(days, str):
if not days.strip():
return [3, 1]
return [int(x.strip()) for x in days.split(',') if x.strip()]
return [3, 1]
except (ValueError, AttributeError):
return [3, 1]
def get_available_languages(self) -> List[str]:
try:
langs = self.AVAILABLE_LANGUAGES
if isinstance(langs, str):
if not langs.strip():
return ["ru", "en"]
return [x.strip() for x in langs.split(',') if x.strip()]
return ["ru", "en"]
except AttributeError:
return ["ru", "en"]
def format_price(self, price_kopeks: int) -> str:
rubles = price_kopeks / 100
return f"{rubles:.2f}"
def kopeks_to_rubles(self, kopeks: int) -> float:
return kopeks / 100
def rubles_to_kopeks(self, rubles: float) -> int:
return int(rubles * 100)
def get_trial_warning_hours(self) -> int:
return self.TRIAL_WARNING_HOURS
def is_notifications_enabled(self) -> bool:
return self.ENABLE_NOTIFICATIONS
def get_app_config_path(self) -> str:
if os.path.isabs(self.APP_CONFIG_PATH):
return self.APP_CONFIG_PATH
project_root = Path(__file__).parent.parent
return str(project_root / self.APP_CONFIG_PATH)
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
def get_app_config_cache_ttl(self) -> int:
return self.APP_CONFIG_CACHE_TTL
def is_traffic_selectable(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
def is_traffic_fixed(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
def is_yookassa_enabled(self) -> bool:
return (self.YOOKASSA_ENABLED and
self.YOOKASSA_SHOP_ID is not None and
self.YOOKASSA_SECRET_KEY is not None)
def get_yookassa_return_url(self) -> str:
if self.YOOKASSA_RETURN_URL:
return self.YOOKASSA_RETURN_URL
elif self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-success"
return "https://t.me/"
def is_maintenance_mode(self) -> bool:
return self.MAINTENANCE_MODE
def get_maintenance_message(self) -> str:
return self.MAINTENANCE_MESSAGE
def get_maintenance_check_interval(self) -> int:
return self.MAINTENANCE_CHECK_INTERVAL
def is_maintenance_auto_enable(self) -> bool:
return self.MAINTENANCE_AUTO_ENABLE
def get_available_subscription_periods(self) -> List[int]:
try:
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
if not periods_str.strip():
return [30, 90, 180]
periods = []
for period_str in periods_str.split(','):
period_str = period_str.strip()
if period_str:
period = int(period_str)
if period in PERIOD_PRICES:
periods.append(period)
return periods if periods else [30, 90, 180]
except (ValueError, AttributeError):
return [30, 90, 180]
def get_available_renewal_periods(self) -> List[int]:
try:
periods_str = self.AVAILABLE_RENEWAL_PERIODS
if not periods_str.strip():
return [30, 90, 180]
periods = []
for period_str in periods_str.split(','):
period_str = period_str.strip()
if period_str:
period = int(period_str)
if period in PERIOD_PRICES:
periods.append(period)
return periods if periods else [30, 90, 180]
except (ValueError, AttributeError):
return [30, 90, 180]
def get_balance_payment_description(self, amount_kopeks: int) -> str:
return self.PAYMENT_BALANCE_TEMPLATE.format(
service_name=self.PAYMENT_SERVICE_NAME,
description=f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
)
def get_subscription_payment_description(self, period_days: int, amount_kopeks: int) -> str:
return self.PAYMENT_SUBSCRIPTION_TEMPLATE.format(
service_name=self.PAYMENT_SERVICE_NAME,
description=f"{self.PAYMENT_SUBSCRIPTION_DESCRIPTION} на {period_days} дней"
)
def get_custom_payment_description(self, description: str) -> str:
return self.PAYMENT_BALANCE_TEMPLATE.format(
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_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 _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"
}
settings = Settings()
PERIOD_PRICES = {
14: settings.PRICE_14_DAYS,
30: settings.PRICE_30_DAYS,
60: settings.PRICE_60_DAYS,
90: settings.PRICE_90_DAYS,
180: settings.PRICE_180_DAYS,
360: settings.PRICE_360_DAYS,
}
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()
+250
View File
@@ -0,0 +1,250 @@
import logging
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User
logger = logging.getLogger(__name__)
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.uses))
.where(PromoCode.code == code.upper())
)
return result.scalar_one_or_none()
async def create_promocode(
db: AsyncSession,
code: str,
type: PromoCodeType,
balance_bonus_kopeks: int = 0,
subscription_days: int = 0,
max_uses: int = 1,
valid_until: Optional[datetime] = None,
created_by: Optional[int] = None
) -> PromoCode:
promocode = PromoCode(
code=code.upper(),
type=type.value,
balance_bonus_kopeks=balance_bonus_kopeks,
subscription_days=subscription_days,
max_uses=max_uses,
valid_until=valid_until,
created_by=created_by
)
db.add(promocode)
await db.commit()
await db.refresh(promocode)
logger.info(f"✅ Создан промокод: {code}")
return promocode
async def use_promocode(
db: AsyncSession,
promocode_id: int,
user_id: int
) -> bool:
try:
promocode = await db.get(PromoCode, promocode_id)
if not promocode:
return False
usage = PromoCodeUse(
promocode_id=promocode_id,
user_id=user_id
)
db.add(usage)
promocode.current_uses += 1
await db.commit()
logger.info(f"✅ Промокод {promocode.code} использован пользователем {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка использования промокода: {e}")
await db.rollback()
return False
async def check_user_promocode_usage(
db: AsyncSession,
user_id: int,
promocode_id: int
) -> bool:
result = await db.execute(
select(PromoCodeUse).where(
and_(
PromoCodeUse.user_id == user_id,
PromoCodeUse.promocode_id == promocode_id
)
)
)
return result.scalar_one_or_none() is not None
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse:
promocode_use = PromoCodeUse(
promocode_id=promocode_id,
user_id=user_id,
used_at=datetime.utcnow()
)
db.add(promocode_use)
await db.commit()
await db.refresh(promocode_use)
logger.info(f"📝 Записано использование промокода {promocode_id} пользователем {user_id}")
return promocode_use
async def get_promocode_use_by_user_and_code(
db: AsyncSession,
user_id: int,
promocode_id: int
) -> Optional[PromoCodeUse]:
result = await db.execute(
select(PromoCodeUse).where(
and_(
PromoCodeUse.user_id == user_id,
PromoCodeUse.promocode_id == promocode_id
)
)
)
return result.scalar_one_or_none()
async def get_user_promocodes(db: AsyncSession, user_id: int) -> List[PromoCodeUse]:
result = await db.execute(
select(PromoCodeUse)
.where(PromoCodeUse.user_id == user_id)
.order_by(PromoCodeUse.used_at.desc())
)
return result.scalars().all()
async def get_promocodes_list(
db: AsyncSession,
offset: int = 0,
limit: int = 50,
is_active: Optional[bool] = None
) -> List[PromoCode]:
query = select(PromoCode).options(selectinload(PromoCode.uses))
if is_active is not None:
query = query.where(PromoCode.is_active == is_active)
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
async def get_promocodes_count(
db: AsyncSession,
is_active: Optional[bool] = None
) -> int:
query = select(func.count(PromoCode.id))
if is_active is not None:
query = query.where(PromoCode.is_active == is_active)
result = await db.execute(query)
return result.scalar()
async def update_promocode(
db: AsyncSession,
promocode: PromoCode,
**kwargs
) -> PromoCode:
for field, value in kwargs.items():
if hasattr(promocode, field):
setattr(promocode, field, value)
promocode.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(promocode)
return promocode
async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
try:
from sqlalchemy import delete as sql_delete
await db.execute(
sql_delete(PromoCodeUse).where(PromoCodeUse.promocode_id == promocode.id)
)
await db.delete(promocode)
await db.commit()
logger.info(f"🗑️ Удален промокод: {promocode.code}")
return True
except Exception as e:
logger.error(f"Ошибка удаления промокода: {e}")
await db.rollback()
return False
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
total_uses_result = await db.execute(
select(func.count(PromoCodeUse.id))
.where(PromoCodeUse.promocode_id == promocode_id)
)
total_uses = total_uses_result.scalar()
today = datetime.utcnow().date()
today_uses_result = await db.execute(
select(func.count(PromoCodeUse.id))
.where(
and_(
PromoCodeUse.promocode_id == promocode_id,
PromoCodeUse.used_at >= today
)
)
)
today_uses = today_uses_result.scalar()
recent_uses_result = await db.execute(
select(PromoCodeUse, User)
.join(User, PromoCodeUse.user_id == User.id)
.where(PromoCodeUse.promocode_id == promocode_id)
.order_by(PromoCodeUse.used_at.desc())
.limit(10)
)
recent_uses_data = recent_uses_result.all()
recent_uses = []
for use, user in recent_uses_data:
use.user_username = user.username
use.user_full_name = user.full_name
use.user_telegram_id = user.telegram_id
recent_uses.append(use)
return {
"total_uses": total_uses,
"today_uses": today_uses,
"recent_uses": recent_uses
}
+305
View File
@@ -0,0 +1,305 @@
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ReferralEarning, User
logger = logging.getLogger(__name__)
async def create_referral_earning(
db: AsyncSession,
user_id: int,
referral_id: int,
amount_kopeks: int,
reason: str,
referral_transaction_id: Optional[int] = None
) -> ReferralEarning:
earning = ReferralEarning(
user_id=user_id,
referral_id=referral_id,
amount_kopeks=amount_kopeks,
reason=reason,
referral_transaction_id=referral_transaction_id
)
db.add(earning)
await db.commit()
await db.refresh(earning)
logger.info(f"💰 Создан реферальный заработок: {amount_kopeks/100}₽ для пользователя {user_id}")
return earning
async def get_referral_earnings_by_user(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[ReferralEarning]:
result = await db.execute(
select(ReferralEarning)
.options(
selectinload(ReferralEarning.referral),
selectinload(ReferralEarning.referral_transaction)
)
.where(ReferralEarning.user_id == user_id)
.order_by(ReferralEarning.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_referral_earnings_by_referral(
db: AsyncSession,
referral_id: int
) -> List[ReferralEarning]:
result = await db.execute(
select(ReferralEarning)
.where(ReferralEarning.referral_id == referral_id)
.order_by(ReferralEarning.created_at.desc())
)
return result.scalars().all()
async def get_referral_earnings_sum(
db: AsyncSession,
user_id: int,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> int:
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == user_id
)
if start_date:
query = query.where(ReferralEarning.created_at >= start_date)
if end_date:
query = query.where(ReferralEarning.created_at <= end_date)
result = await db.execute(query)
return result.scalar()
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))
)
users_with_referrals = users_with_referrals_result.scalar()
active_referrers_result = await db.execute(
select(func.count(func.distinct(User.referred_by_id)))
.where(User.referred_by_id.isnot(None))
)
active_referrers = active_referrers_result.scalar()
referral_paid_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
)
referral_paid = referral_paid_result.scalar()
from app.database.models import Transaction, TransactionType
transaction_paid_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
)
transaction_paid = transaction_paid_result.scalar()
total_paid = referral_paid + transaction_paid
referrals_stats_result = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('referrals_count')
)
.where(User.referred_by_id.isnot(None))
.group_by(User.referred_by_id)
)
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 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 == referrer_id)
)
user = user_result.first()
if user:
display_name = ""
if user.first_name:
display_name = user.first_name
if user.last_name:
display_name += f" {user.last_name}"
elif user.username:
display_name = f"@{user.username}"
else:
display_name = f"ID{user.telegram_id}"
top_referrers.append({
"user_id": user.telegram_id,
"display_name": display_name,
"username": user.username,
"telegram_id": user.telegram_id,
"total_earned_kopeks": stats['total_earned'],
"referrals_count": stats['referrals_count']
})
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.created_at >= today)
)
today_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= today
)
)
)
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
week_ago = datetime.utcnow() - timedelta(days=7)
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_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= week_ago
)
)
)
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
month_ago = datetime.utcnow() - timedelta(days=30)
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_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= month_ago
)
)
)
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,
"active_referrers": active_referrers,
"total_paid_kopeks": total_paid,
"today_earnings_kopeks": today_earnings,
"week_earnings_kopeks": week_earnings,
"month_earnings_kopeks": month_earnings,
"top_referrers": top_referrers
}
async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
invited_count_result = await db.execute(
select(func.count(User.id)).where(User.referred_by_id == user_id)
)
invited_count = invited_count_result.scalar()
total_earned = await get_referral_earnings_sum(db, user_id)
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(Subscription, User.id == Subscription.user_id)
.where(
and_(
User.referred_by_id == user_id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > current_time
)
)
)
active_referrals = active_referrals_result.scalar()
return {
"invited_count": invited_count,
"active_referrals": active_referrals,
"total_earned_kopeks": total_earned,
"month_earned_kopeks": month_earned
}
+82
View File
@@ -0,0 +1,82 @@
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from app.database.models import ServiceRule
logger = logging.getLogger(__name__)
async def get_rules_by_language(db: AsyncSession, language: str = "ru") -> Optional[ServiceRule]:
result = await db.execute(
select(ServiceRule)
.where(
ServiceRule.language == language,
ServiceRule.is_active == True
)
.order_by(ServiceRule.order, ServiceRule.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def create_or_update_rules(
db: AsyncSession,
content: str,
language: str = "ru",
title: str = "Правила сервиса"
) -> ServiceRule:
existing_rules_result = await db.execute(
select(ServiceRule).where(
ServiceRule.language == language,
ServiceRule.is_active == True
)
)
existing_rules = existing_rules_result.scalars().all()
for rule in existing_rules:
rule.is_active = False
rule.updated_at = datetime.utcnow()
new_rules = ServiceRule(
title=title,
content=content,
language=language,
is_active=True,
order=0
)
db.add(new_rules)
await db.commit()
await db.refresh(new_rules)
logger.info(f"✅ Правила для языка {language} обновлены")
return new_rules
async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str:
rules = await get_rules_by_language(db, language)
if rules:
return rules.content
else:
return """
🔒 <b>Правила использования сервиса</b>
1. Сервис предоставляется "как есть" без каких-либо гарантий.
2. Запрещается использование сервиса для незаконных действий.
3. Администрация оставляет за собой право заблокировать доступ пользователя при нарушении правил.
4. Возврат средств осуществляется в соответствии с политикой возврата.
5. Пользователь несет полную ответственность за безопасность своего аккаунта.
6. При возникновении вопросов обращайтесь в техническую поддержку.
Используя сервис, вы соглашаетесь с данными правилами.
"""
+355
View File
@@ -0,0 +1,355 @@
import logging
from typing import List, Optional, Tuple
from sqlalchemy import select, and_, func, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ServerSquad, SubscriptionServer
logger = logging.getLogger(__name__)
async def create_server_squad(
db: AsyncSession,
squad_uuid: str,
display_name: str,
original_name: str = None,
country_code: str = None,
price_kopeks: int = 0,
description: str = None,
max_users: int = None,
is_available: bool = True
) -> ServerSquad:
server_squad = ServerSquad(
squad_uuid=squad_uuid,
display_name=display_name,
original_name=original_name,
country_code=country_code,
price_kopeks=price_kopeks,
description=description,
max_users=max_users,
is_available=is_available
)
db.add(server_squad)
await db.commit()
await db.refresh(server_squad)
logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})")
return server_squad
async def get_server_squad_by_uuid(
db: AsyncSession,
squad_uuid: str
) -> Optional[ServerSquad]:
result = await db.execute(
select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid)
)
return result.scalar_one_or_none()
async def get_server_squad_by_id(
db: AsyncSession,
server_id: int
) -> Optional[ServerSquad]:
result = await db.execute(
select(ServerSquad).where(ServerSquad.id == server_id)
)
return result.scalar_one_or_none()
async def get_all_server_squads(
db: AsyncSession,
available_only: bool = False,
page: int = 1,
limit: int = 50
) -> Tuple[List[ServerSquad], int]:
query = select(ServerSquad)
if available_only:
query = query.where(ServerSquad.is_available == True)
count_query = select(func.count(ServerSquad.id))
if available_only:
count_query = count_query.where(ServerSquad.is_available == True)
count_result = await db.execute(count_query)
total_count = count_result.scalar()
offset = (page - 1) * limit
query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
servers = result.scalars().all()
return servers, total_count
async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]:
result = await db.execute(
select(ServerSquad)
.where(ServerSquad.is_available == True)
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
)
return result.scalars().all()
async def update_server_squad(
db: AsyncSession,
server_id: int,
**updates
) -> Optional[ServerSquad]:
valid_fields = {
'display_name', 'country_code', 'price_kopeks', 'description',
'max_users', 'is_available', 'sort_order'
}
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
if not filtered_updates:
return None
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(**filtered_updates)
)
await db.commit()
return await get_server_squad_by_id(db, server_id)
async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
connections_result = await db.execute(
select(func.count(SubscriptionServer.id))
.where(SubscriptionServer.server_squad_id == server_id)
)
connections_count = connections_result.scalar()
if connections_count > 0:
logger.warning(f"❌ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
return False
await db.execute(
delete(ServerSquad).where(ServerSquad.id == server_id)
)
await db.commit()
logger.info(f"🗑️ Удален сервер (ID: {server_id})")
return True
async def sync_with_remnawave(
db: AsyncSession,
remnawave_squads: List[dict]
) -> Tuple[int, int, int]:
created = 0
updated = 0
disabled = 0
existing_servers = {}
result = await db.execute(select(ServerSquad))
for server in result.scalars().all():
existing_servers[server.squad_uuid] = server
remnawave_uuids = {squad['uuid'] for squad in remnawave_squads}
for squad in remnawave_squads:
squad_uuid = squad['uuid']
original_name = squad.get('name', f'Squad {squad_uuid[:8]}')
if squad_uuid in existing_servers:
server = existing_servers[squad_uuid]
if server.original_name != original_name:
server.original_name = original_name
updated += 1
else:
await create_server_squad(
db=db,
squad_uuid=squad_uuid,
display_name=_generate_display_name(original_name),
original_name=original_name,
country_code=_extract_country_code(original_name),
price_kopeks=1000,
is_available=False
)
created += 1
for uuid, server in existing_servers.items():
if uuid not in remnawave_uuids and server.is_available:
server.is_available = False
disabled += 1
await db.commit()
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
return created, updated, disabled
def _generate_display_name(original_name: str) -> str:
country_names = {
'NL': '🇳🇱 Нидерланды',
'DE': '🇩🇪 Германия',
'US': '🇺🇸 США',
'FR': '🇫🇷 Франция',
'GB': '🇬🇧 Великобритания',
'IT': '🇮🇹 Италия',
'ES': '🇪🇸 Испания',
'CA': '🇨🇦 Канада',
'JP': '🇯🇵 Япония',
'SG': '🇸🇬 Сингапур',
'AU': '🇦🇺 Австралия',
}
name_upper = original_name.upper()
for code, display_name in country_names.items():
if code in name_upper:
return display_name
return f"🌍 {original_name}"
def _extract_country_code(original_name: str) -> Optional[str]:
codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU']
name_upper = original_name.upper()
for code in codes:
if code in name_upper:
return code
return None
async def get_server_statistics(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(ServerSquad.id)))
total_servers = total_result.scalar()
available_result = await db.execute(
select(func.count(ServerSquad.id))
.where(ServerSquad.is_available == True)
)
available_servers = available_result.scalar()
with_connections_result = await db.execute(
select(func.count(func.distinct(SubscriptionServer.server_squad_id)))
)
servers_with_connections = with_connections_result.scalar()
revenue_result = await db.execute(
select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0))
)
total_revenue_kopeks = revenue_result.scalar()
return {
'total_servers': total_servers,
'available_servers': available_servers,
'unavailable_servers': total_servers - available_servers,
'servers_with_connections': servers_with_connections,
'total_revenue_kopeks': total_revenue_kopeks,
'total_revenue_rubles': total_revenue_kopeks / 100
}
async def add_user_to_servers(
db: AsyncSession,
server_squad_ids: List[int]
) -> bool:
try:
for server_id in server_squad_ids:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=ServerSquad.current_users + 1)
)
await db.commit()
logger.info(f"✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}")
return True
except Exception as e:
logger.error(f"Ошибка увеличения счетчика пользователей: {e}")
await db.rollback()
return False
async def remove_user_from_servers(
db: AsyncSession,
server_squad_ids: List[int]
) -> bool:
try:
for server_id in server_squad_ids:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
)
await db.commit()
logger.info(f"✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}")
return True
except Exception as e:
logger.error(f"Ошибка уменьшения счетчика пользователей: {e}")
await db.rollback()
return False
async def get_server_ids_by_uuids(
db: AsyncSession,
squad_uuids: List[str]
) -> List[int]:
result = await db.execute(
select(ServerSquad.id)
.where(ServerSquad.squad_uuid.in_(squad_uuids))
)
return [row[0] for row in result.fetchall()]
async def sync_server_user_counts(db: AsyncSession) -> int:
try:
result = await db.execute(
select(
ServerSquad.id,
ServerSquad.squad_uuid,
func.count(SubscriptionServer.id).label('actual_users')
)
.outerjoin(SubscriptionServer, ServerSquad.id == SubscriptionServer.server_squad_id)
.join(Subscription, SubscriptionServer.subscription_id == Subscription.id)
.where(Subscription.status == 'active')
.group_by(ServerSquad.id, ServerSquad.squad_uuid)
)
updated_count = 0
for server_id, squad_uuid, actual_users in result.fetchall():
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=actual_users)
)
updated_count += 1
await db.commit()
logger.info(f"✅ Синхронизированы счетчики для {updated_count} серверов")
return updated_count
except Exception as e:
logger.error(f"Ошибка синхронизации счетчиков пользователей: {e}")
await db.rollback()
return 0
+61
View File
@@ -0,0 +1,61 @@
import logging
from typing import Optional, List
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Squad
logger = logging.getLogger(__name__)
async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Optional[Squad]:
result = await db.execute(
select(Squad).where(Squad.uuid == uuid)
)
return result.scalar_one_or_none()
async def get_available_squads(db: AsyncSession) -> List[Squad]:
result = await db.execute(
select(Squad).where(Squad.is_available == True)
)
return result.scalars().all()
async def create_squad(
db: AsyncSession,
uuid: str,
name: str,
country_code: str = None,
price_kopeks: int = 0,
description: str = None
) -> Squad:
squad = Squad(
uuid=uuid,
name=name,
country_code=country_code,
price_kopeks=price_kopeks,
description=description
)
db.add(squad)
await db.commit()
await db.refresh(squad)
logger.info(f"✅ Создан сквад: {name}")
return squad
async def update_squad(
db: AsyncSession,
squad: Squad,
**kwargs
) -> Squad:
for field, value in kwargs.items():
if hasattr(squad, field):
setattr(squad, field, value)
await db.commit()
await db.refresh(squad)
return squad
+775
View File
@@ -0,0 +1,775 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
Subscription, SubscriptionStatus, User,
SubscriptionServer
)
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.config import settings
logger = logging.getLogger(__name__)
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
.limit(1)
)
subscription = result.scalar_one_or_none()
if subscription:
subscription = await check_and_update_subscription_status(db, subscription)
return subscription
async def create_trial_subscription(
db: AsyncSession,
user_id: int,
duration_days: int = None,
traffic_limit_gb: int = None,
device_limit: int = None,
squad_uuid: str = None
) -> Subscription:
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
end_date = datetime.utcnow() + timedelta(days=duration_days)
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
is_trial=True,
start_date=datetime.utcnow(),
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=[squad_uuid] if squad_uuid else []
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
return subscription
async def create_paid_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
is_trial=False,
start_date=datetime.utcnow(),
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or []
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
return subscription
async def extend_subscription(
db: AsyncSession,
subscription: Subscription,
days: int
) -> Subscription:
current_time = datetime.utcnow()
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
if subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
if subscription.status == SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
return subscription
async def add_subscription_traffic(
db: AsyncSession,
subscription: Subscription,
gb: int
) -> Subscription:
subscription.add_traffic(gb)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика")
return subscription
async def add_subscription_devices(
db: AsyncSession,
subscription: Subscription,
devices: int
) -> Subscription:
subscription.device_limit += devices
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f"📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств")
return subscription
async def add_subscription_squad(
db: AsyncSession,
subscription: Subscription,
squad_uuid: str
) -> Subscription:
if squad_uuid not in subscription.connected_squads:
subscription.connected_squads = subscription.connected_squads + [squad_uuid]
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f"🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}")
return subscription
async def remove_subscription_squad(
db: AsyncSession,
subscription: Subscription,
squad_uuid: str
) -> Subscription:
if squad_uuid in subscription.connected_squads:
squads = subscription.connected_squads.copy()
squads.remove(squad_uuid)
subscription.connected_squads = squads
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f"🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}")
return subscription
async def update_subscription_autopay(
db: AsyncSession,
subscription: Subscription,
enabled: bool,
days_before: int = 3
) -> Subscription:
subscription.autopay_enabled = enabled
subscription.autopay_days_before = days_before
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
status = "включен" if enabled else "выключен"
logger.info(f"💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}")
return subscription
async def deactivate_subscription(
db: AsyncSession,
subscription: Subscription
) -> Subscription:
subscription.status = SubscriptionStatus.DISABLED.value
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f"❌ Подписка пользователя {subscription.user_id} деактивирована")
return subscription
async def get_expiring_subscriptions(
db: AsyncSession,
days_before: int = 3
) -> List[Subscription]:
threshold_date = datetime.utcnow() + timedelta(days=days_before)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date <= threshold_date,
Subscription.end_date > datetime.utcnow()
)
)
)
return result.scalars().all()
async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date <= datetime.utcnow()
)
)
)
return result.scalars().all()
async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]:
current_time = datetime.utcnow()
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.autopay_enabled == True,
Subscription.is_trial == False
)
)
)
all_autopay_subscriptions = result.scalars().all()
ready_for_autopay = []
for subscription in all_autopay_subscriptions:
days_until_expiry = (subscription.end_date - current_time).days
if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time:
ready_for_autopay.append(subscription)
return ready_for_autopay
async def get_subscriptions_statistics(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(Subscription.id)))
total_subscriptions = total_result.scalar()
active_result = await db.execute(
select(func.count(Subscription.id))
.where(Subscription.status == SubscriptionStatus.ACTIVE.value)
)
active_subscriptions = active_result.scalar()
trial_result = await db.execute(
select(func.count(Subscription.id))
.where(
and_(
Subscription.is_trial == True,
Subscription.status == SubscriptionStatus.ACTIVE.value
)
)
)
trial_subscriptions = trial_result.scalar()
paid_subscriptions = active_subscriptions - trial_subscriptions
today = datetime.utcnow().date()
today_result = await db.execute(
select(func.count(Subscription.id))
.where(
and_(
Subscription.created_at >= today,
Subscription.is_trial == False
)
)
)
purchased_today = today_result.scalar()
week_ago = datetime.utcnow() - timedelta(days=7)
week_result = await db.execute(
select(func.count(Subscription.id))
.where(
and_(
Subscription.created_at >= week_ago,
Subscription.is_trial == False
)
)
)
purchased_week = week_result.scalar()
month_ago = datetime.utcnow() - timedelta(days=30)
month_result = await db.execute(
select(func.count(Subscription.id))
.where(
and_(
Subscription.created_at >= month_ago,
Subscription.is_trial == False
)
)
)
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,
"trial_subscriptions": trial_subscriptions,
"paid_subscriptions": paid_subscriptions,
"purchased_today": purchased_today,
"purchased_week": purchased_week,
"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,
used_gb: float
) -> Subscription:
subscription.traffic_used_gb = used_gb
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
return subscription
async def get_all_subscriptions(
db: AsyncSession,
page: int = 1,
limit: int = 10
) -> Tuple[List[Subscription], int]:
count_result = await db.execute(
select(func.count(Subscription.id))
)
total_count = count_result.scalar()
offset = (page - 1) * limit
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.order_by(Subscription.created_at.desc())
.offset(offset)
.limit(limit)
)
subscriptions = result.scalars().all()
return subscriptions, total_count
async def add_subscription_servers(
db: AsyncSession,
subscription: Subscription,
server_squad_ids: List[int],
paid_prices: List[int] = None
) -> Subscription:
if paid_prices is None:
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(
subscription_id=subscription.id,
server_squad_id=server_id,
paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0
)
db.add(subscription_server)
await db.commit()
await db.refresh(subscription)
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
) -> List[int]:
result = await db.execute(
select(SubscriptionServer.server_squad_id)
.where(SubscriptionServer.subscription_id == subscription_id)
)
return [row[0] for row in result.fetchall()]
async def get_subscription_servers(
db: AsyncSession,
subscription_id: int
) -> List[dict]:
from app.database.models import ServerSquad
result = await db.execute(
select(SubscriptionServer, ServerSquad)
.join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id)
.where(SubscriptionServer.subscription_id == subscription_id)
)
servers_info = []
for sub_server, server_squad in result.fetchall():
servers_info.append({
'server_id': server_squad.id,
'squad_uuid': server_squad.squad_uuid,
'display_name': server_squad.display_name,
'country_code': server_squad.country_code,
'paid_price_kopeks': sub_server.paid_price_kopeks,
'connected_at': sub_server.connected_at,
'is_available': server_squad.is_available
})
return servers_info
async def remove_subscription_servers(
db: AsyncSession,
subscription_id: int,
server_squad_ids: List[int]
) -> bool:
try:
from app.database.models import SubscriptionServer
from sqlalchemy import delete
await db.execute(
delete(SubscriptionServer)
.where(
SubscriptionServer.subscription_id == subscription_id,
SubscriptionServer.server_squad_id.in_(server_squad_ids)
)
)
await db.commit()
logger.info(f"🗑️ Удалены серверы {server_squad_ids} из подписки {subscription_id}")
return True
except Exception as e:
logger.error(f"Ошибка удаления серверов из подписки: {e}")
await db.rollback()
return False
async def get_subscription_renewal_cost(
db: AsyncSession,
subscription_id: int,
period_days: int
) -> 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)
subscription = await db.get(Subscription, subscription_id)
if not subscription:
return base_price
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
total_servers_cost = servers_price_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
total_traffic_cost = traffic_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_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_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
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_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
) -> Subscription:
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f"⏰ Подписка пользователя {subscription.user_id} помечена как истёкшая")
return subscription
async def check_and_update_subscription_status(
db: AsyncSession,
subscription: Subscription
) -> Subscription:
current_time = datetime.utcnow()
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
return subscription
async def create_subscription(
db: AsyncSession,
user_id: int,
status: str = "trial",
is_trial: bool = True,
end_date: datetime = None,
traffic_limit_gb: int = 10,
traffic_used_gb: float = 0.0,
device_limit: int = 1,
connected_squads: list = None,
remnawave_short_uuid: str = None,
subscription_url: str = ""
) -> Subscription:
if end_date is None:
end_date = datetime.utcnow() + timedelta(days=3)
if connected_squads is None:
connected_squads = []
subscription = Subscription(
user_id=user_id,
status=status,
is_trial=is_trial,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
device_limit=device_limit,
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
subscription_url=subscription_url
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(f"✅ Создана подписка для пользователя {user_id}")
return 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)
+358
View File
@@ -0,0 +1,358 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select, and_, or_, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import Transaction, TransactionType, PaymentMethod, User
logger = logging.getLogger(__name__)
async def create_transaction(
db: AsyncSession,
user_id: int,
type: TransactionType,
amount_kopeks: int,
description: str,
payment_method: Optional[PaymentMethod] = None,
external_id: Optional[str] = None,
is_completed: bool = True
) -> Transaction:
transaction = Transaction(
user_id=user_id,
type=type.value,
amount_kopeks=amount_kopeks,
description=description,
payment_method=payment_method.value if payment_method else None,
external_id=external_id,
is_completed=is_completed,
completed_at=datetime.utcnow() if is_completed else None
)
db.add(transaction)
await db.commit()
await db.refresh(transaction)
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
return transaction
async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Optional[Transaction]:
result = await db.execute(
select(Transaction)
.options(selectinload(Transaction.user))
.where(Transaction.id == transaction_id)
)
return result.scalar_one_or_none()
async def get_transaction_by_external_id(
db: AsyncSession,
external_id: str,
payment_method: PaymentMethod
) -> Optional[Transaction]:
result = await db.execute(
select(Transaction)
.where(
and_(
Transaction.external_id == external_id,
Transaction.payment_method == payment_method.value
)
)
)
return result.scalar_one_or_none()
async def get_user_transactions(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[Transaction]:
result = await db.execute(
select(Transaction)
.where(Transaction.user_id == user_id)
.order_by(Transaction.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_user_transactions_count(
db: AsyncSession,
user_id: int,
transaction_type: Optional[TransactionType] = None
) -> int:
query = select(func.count(Transaction.id)).where(Transaction.user_id == user_id)
if transaction_type:
query = query.where(Transaction.type == transaction_type.value)
result = await db.execute(query)
return result.scalar()
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
transaction.is_completed = True
transaction.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(transaction)
logger.info(f"✅ Транзакция {transaction.id} завершена")
return transaction
async def get_pending_transactions(db: AsyncSession) -> List[Transaction]:
result = await db.execute(
select(Transaction)
.options(selectinload(Transaction.user))
.where(Transaction.is_completed == False)
.order_by(Transaction.created_at)
)
return result.scalars().all()
async def get_transactions_statistics(
db: AsyncSession,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> dict:
if not start_date:
start_date = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if not end_date:
end_date = datetime.utcnow()
income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
)
)
)
total_income = income_result.scalar()
expenses_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.WITHDRAWAL.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
)
)
)
total_expenses = expenses_result.scalar()
subscription_income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
)
)
)
subscription_income = subscription_income_result.scalar()
transactions_count_result = await db.execute(
select(
Transaction.type,
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount')
)
.where(
and_(
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
)
)
.group_by(Transaction.type)
)
transactions_by_type = {row.type: {"count": row.count, "amount": row.total_amount}
for row in transactions_count_result}
payment_methods_result = await db.execute(
select(
Transaction.payment_method,
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount')
)
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date,
Transaction.created_at <= end_date
)
)
.group_by(Transaction.payment_method)
)
payment_methods = {row.payment_method: {"count": row.count, "amount": row.total_amount}
for row in payment_methods_result}
today = datetime.utcnow().date()
today_result = await db.execute(
select(func.count(Transaction.id))
.where(
and_(
Transaction.is_completed == True,
Transaction.created_at >= today
)
)
)
transactions_today = today_result.scalar()
today_income_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= today
)
)
)
income_today = today_income_result.scalar()
return {
"period": {
"start_date": start_date,
"end_date": end_date
},
"totals": {
"income_kopeks": total_income,
"expenses_kopeks": total_expenses,
"profit_kopeks": total_income - total_expenses,
"subscription_income_kopeks": subscription_income
},
"today": {
"transactions_count": transactions_today,
"income_kopeks": income_today
},
"by_type": transactions_by_type,
"by_payment_method": payment_methods
}
async def get_revenue_by_period(
db: AsyncSession,
days: int = 30
) -> List[dict]:
start_date = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(
func.date(Transaction.created_at).label('date'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount')
)
.where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
Transaction.created_at >= start_date
)
)
.group_by(func.date(Transaction.created_at))
.order_by(func.date(Transaction.created_at))
)
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))
conditions = [
Transaction.external_id == f"donation_{payment_id}",
Transaction.external_id == payment_id,
Transaction.external_id.like(f"%{payment_id}%")
]
query = query.where(
and_(
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
or_(*conditions)
)
)
if user_telegram_id:
from app.database.models import User
query = query.join(User).where(User.telegram_id == user_telegram_id)
result = await db.execute(query.order_by(Transaction.created_at.desc()))
return result.scalars().all()
async def check_tribute_payment_duplicate(
db: AsyncSession,
payment_id: str,
amount_kopeks: int,
user_telegram_id: int
) -> Optional[Transaction]:
transactions = await find_tribute_transactions_by_payment_id(
db, payment_id, user_telegram_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
return None
async def create_unique_tribute_transaction(
db: AsyncSession,
user_id: int,
payment_id: str,
amount_kopeks: int,
description: str
) -> Transaction:
"""Создать уникальную Tribute транзакцию с защитой от дубликатов"""
external_id = f"donation_{payment_id}"
existing = await get_transaction_by_external_id(db, external_id, PaymentMethod.TRIBUTE)
if existing:
timestamp = int(datetime.utcnow().timestamp())
external_id = f"donation_{payment_id}_{amount_kopeks}_{timestamp}"
logger.info(f"Создан уникальный external_id для избежания дубликатов: {external_id}")
return await create_transaction(
db=db,
user_id=user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description,
payment_method=PaymentMethod.TRIBUTE,
external_id=external_id,
is_completed=True
)
+374
View File
@@ -0,0 +1,374 @@
import logging
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import User, UserStatus, Subscription, Transaction
from app.config import settings
logger = logging.getLogger(__name__)
def generate_referral_code() -> str:
alphabet = string.ascii_letters + string.digits
code_suffix = ''.join(secrets.choice(alphabet) for _ in range(8))
return f"ref{code_suffix}"
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
_ = user.subscription.is_active
return user
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
_ = user.subscription.is_active
return user
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)
)
return result.scalar_one_or_none()
async def create_unique_referral_code(db: AsyncSession) -> str:
max_attempts = 10
for _ in range(max_attempts):
code = generate_referral_code()
existing_user = await get_user_by_referral_code(db, code)
if not existing_user:
return code
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
return f"ref{timestamp}"
async def create_user(
db: AsyncSession,
telegram_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language: str = "ru",
referred_by_id: int = None,
referral_code: str = None
) -> User:
if not referral_code:
from app.utils.user_utils import generate_unique_referral_code
referral_code = await generate_unique_referral_code(db, telegram_id)
user = User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False
)
db.add(user)
await db.commit()
await db.refresh(user)
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
return user
async def update_user(
db: AsyncSession,
user: User,
**kwargs
) -> User:
for field, value in kwargs.items():
if hasattr(user, field):
setattr(user, field, value)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return user
async def add_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
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()
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)
topup_keywords = ["пополнение", "stars", "yookassa", "topup"]
exclude_keywords = ["комиссия", "бонус", "реферальн", "выплата", "вознаграждение"]
has_topup_keywords = any(word in description.lower() for word in topup_keywords)
has_exclude_keywords = any(word in description.lower() for word in exclude_keywords)
if has_topup_keywords and not has_exclude_keywords:
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, amount_kopeks, bot)
except Exception as e:
logger.error(f"Ошибка обработки реферального пополнения: {e}")
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})")
return True
except Exception as e:
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
await db.rollback()
return False
async def add_user_balance_by_id(
db: AsyncSession,
telegram_id: int,
amount_kopeks: int,
description: str = "Пополнение баланса"
) -> bool:
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
return False
return await add_user_balance(db, user, amount_kopeks, description)
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,
user: User,
amount_kopeks: int,
description: str
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}")
if user.balance_kopeks < amount_kopeks:
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
return False
try:
old_balance = user.balance_kopeks
user.balance_kopeks -= amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
except Exception as e:
logger.error(f" ❌ ОШИБКА СПИСАНИЯ: {e}")
await db.rollback()
return False
async def get_users_list(
db: AsyncSession,
offset: int = 0,
limit: int = 50,
search: Optional[str] = None,
status: Optional[UserStatus] = None
) -> List[User]:
query = select(User).options(selectinload(User.subscription))
if status:
query = query.where(User.status == status.value)
if search:
search_term = f"%{search}%"
conditions = [
User.first_name.ilike(search_term),
User.last_name.ilike(search_term),
User.username.ilike(search_term)
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
query = query.where(or_(*conditions))
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
async def get_users_count(
db: AsyncSession,
status: Optional[UserStatus] = None,
search: Optional[str] = None
) -> int:
query = select(func.count(User.id))
if status:
query = query.where(User.status == status.value)
if search:
search_term = f"%{search}%"
conditions = [
User.first_name.ilike(search_term),
User.last_name.ilike(search_term),
User.username.ilike(search_term)
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
query = query.where(or_(*conditions))
result = await db.execute(query)
return result.scalar()
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.where(User.referred_by_id == user_id)
.order_by(User.created_at.desc())
)
return result.scalars().all()
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.where(
and_(
User.last_activity < threshold_date,
User.status == UserStatus.ACTIVE.value
)
)
)
return result.scalars().all()
async def delete_user(db: AsyncSession, user: User) -> bool:
user.status = UserStatus.DELETED.value
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"🗑️ Пользователь {user.telegram_id} помечен как удаленный")
return True
async def get_users_statistics(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(User.id)))
total_users = total_result.scalar()
active_result = await db.execute(
select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value)
)
active_users = active_result.scalar()
today = datetime.utcnow().date()
today_result = await db.execute(
select(func.count(User.id)).where(
and_(
User.created_at >= today,
User.status == UserStatus.ACTIVE.value
)
)
)
new_today = today_result.scalar()
week_ago = datetime.utcnow() - timedelta(days=7)
week_result = await db.execute(
select(func.count(User.id)).where(
and_(
User.created_at >= week_ago,
User.status == UserStatus.ACTIVE.value
)
)
)
new_week = week_result.scalar()
month_ago = datetime.utcnow() - timedelta(days=30)
month_result = await db.execute(
select(func.count(User.id)).where(
and_(
User.created_at >= month_ago,
User.status == UserStatus.ACTIVE.value
)
)
)
new_month = month_result.scalar()
return {
"total_users": total_users,
"active_users": active_users,
"blocked_users": total_users - active_users,
"new_today": new_today,
"new_week": new_week,
"new_month": new_month
}
+276
View File
@@ -0,0 +1,276 @@
import logging
from typing import Optional, List
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, and_
from sqlalchemy.orm import selectinload
from app.database.models import YooKassaPayment, User, Transaction
logger = logging.getLogger(__name__)
async def create_yookassa_payment(
db: AsyncSession,
user_id: int,
yookassa_payment_id: str,
amount_kopeks: int,
currency: str,
description: str,
status: str,
confirmation_url: Optional[str] = None,
metadata_json: Optional[dict] = None,
payment_method_type: Optional[str] = None,
yookassa_created_at: Optional[datetime] = None,
test_mode: bool = False
) -> YooKassaPayment:
payment = YooKassaPayment(
user_id=user_id,
yookassa_payment_id=yookassa_payment_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
confirmation_url=confirmation_url,
metadata_json=metadata_json,
payment_method_type=payment_method_type,
yookassa_created_at=yookassa_created_at,
test_mode=test_mode
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f"Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks/100}₽ для пользователя {user_id}")
return payment
async def get_yookassa_payment_by_id(
db: AsyncSession,
yookassa_payment_id: str
) -> Optional[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
return result.scalar_one_or_none()
async def get_yookassa_payment_by_local_id(
db: AsyncSession,
local_id: int
) -> Optional[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.id == local_id)
)
return result.scalar_one_or_none()
async def update_yookassa_payment_status(
db: AsyncSession,
yookassa_payment_id: str,
status: str,
is_paid: bool = False,
is_captured: bool = False,
captured_at: Optional[datetime] = None,
payment_method_type: Optional[str] = None
) -> Optional[YooKassaPayment]:
update_data = {
"status": status,
"is_paid": is_paid,
"is_captured": is_captured,
"updated_at": datetime.utcnow()
}
if captured_at:
update_data["captured_at"] = captured_at
if payment_method_type:
update_data["payment_method_type"] = payment_method_type
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
.values(**update_data)
)
await db.commit()
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Обновлен статус платежа YooKassa {yookassa_payment_id}: {status}, paid={is_paid}")
return payment
async def link_yookassa_payment_to_transaction(
db: AsyncSession,
yookassa_payment_id: str,
transaction_id: int
) -> Optional[YooKassaPayment]:
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
.values(transaction_id=transaction_id, updated_at=datetime.utcnow())
)
await db.commit()
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user), selectinload(YooKassaPayment.transaction))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Платеж YooKassa {yookassa_payment_id} связан с транзакцией {transaction_id}")
return payment
async def get_user_yookassa_payments(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.transaction))
.where(YooKassaPayment.user_id == user_id)
.order_by(YooKassaPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return result.scalars().all()
async def get_pending_yookassa_payments(
db: AsyncSession,
user_id: Optional[int] = None,
limit: int = 100
) -> List[YooKassaPayment]:
query = select(YooKassaPayment).options(selectinload(YooKassaPayment.user))
conditions = [YooKassaPayment.status.in_(["pending", "waiting_for_capture"])]
if user_id:
conditions.append(YooKassaPayment.user_id == user_id)
result = await db.execute(
query.where(and_(*conditions))
.order_by(YooKassaPayment.created_at.desc())
.limit(limit)
)
return result.scalars().all()
async def get_succeeded_yookassa_payments_without_transaction(
db: AsyncSession,
limit: int = 50
) -> List[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(
and_(
YooKassaPayment.status == "succeeded",
YooKassaPayment.is_paid == True,
YooKassaPayment.transaction_id == None
)
)
.order_by(YooKassaPayment.captured_at.desc())
.limit(limit)
)
return result.scalars().all()
async def delete_yookassa_payment(
db: AsyncSession,
yookassa_payment_id: str
) -> bool:
result = await db.execute(
select(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
await db.delete(payment)
await db.commit()
logger.info(f"Удален платеж YooKassa: {yookassa_payment_id}")
return True
return False
async def get_yookassa_payments_stats(
db: AsyncSession,
user_id: Optional[int] = None
) -> dict:
from sqlalchemy import func, case
query = select(
func.count(YooKassaPayment.id).label('total_payments'),
func.sum(YooKassaPayment.amount_kopeks).label('total_amount_kopeks'),
func.sum(
case(
(YooKassaPayment.status == 'succeeded', YooKassaPayment.amount_kopeks),
else_=0
)
).label('succeeded_amount_kopeks'),
func.count(
case(
(YooKassaPayment.status == 'succeeded', 1),
else_=None
)
).label('succeeded_count'),
func.count(
case(
(YooKassaPayment.status == 'pending', 1),
else_=None
)
).label('pending_count'),
func.count(
case(
(YooKassaPayment.status.in_(['canceled', 'failed']), 1),
else_=None
)
).label('failed_count')
).select_from(YooKassaPayment)
if user_id:
query = query.where(YooKassaPayment.user_id == user_id)
result = await db.execute(query)
stats = result.first()
return {
'total_payments': stats.total_payments or 0,
'total_amount_kopeks': stats.total_amount_kopeks or 0,
'total_amount_rubles': (stats.total_amount_kopeks or 0) / 100,
'succeeded_amount_kopeks': stats.succeeded_amount_kopeks or 0,
'succeeded_amount_rubles': (stats.succeeded_amount_kopeks or 0) / 100,
'succeeded_count': stats.succeeded_count or 0,
'pending_count': stats.pending_count or 0,
'failed_count': stats.failed_count or 0,
'success_rate': (stats.succeeded_count / stats.total_payments * 100) if stats.total_payments > 0 else 0
}
+51
View File
@@ -0,0 +1,51 @@
import logging
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from app.config import settings
from app.database.models import Base
logger = logging.getLogger(__name__)
engine = create_async_engine(
settings.DATABASE_URL,
poolclass=NullPool,
echo=settings.DEBUG,
future=True
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=True,
autocommit=False
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
logger.info("Создание таблиц базы данных...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("✅ База данных успешно инициализирована")
async def close_db():
await engine.dispose()
logger.info("✅ Подключение к базе данных закрыто")
+584
View File
@@ -0,0 +1,584 @@
from datetime import datetime, timedelta
from typing import Optional, List
from enum import Enum
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Text,
ForeignKey, Float, JSON, BigInteger
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Mapped, mapped_column
from sqlalchemy.sql import func
Base = declarative_base()
class UserStatus(Enum):
ACTIVE = "active"
BLOCKED = "blocked"
DELETED = "deleted"
class SubscriptionStatus(Enum):
TRIAL = "trial"
ACTIVE = "active"
EXPIRED = "expired"
DISABLED = "disabled"
class TransactionType(Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
class PromoCodeType(Enum):
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
class PaymentMethod(Enum):
TELEGRAM_STARS = "telegram_stars"
TRIBUTE = "tribute"
YOOKASSA = "yookassa"
MANUAL = "manual"
class YooKassaPayment(Base):
__tablename__ = "yookassa_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
yookassa_payment_id = Column(String(255), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(3), default="RUB", nullable=False)
description = Column(Text, nullable=True)
status = Column(String(50), nullable=False)
is_paid = Column(Boolean, default=False)
is_captured = Column(Boolean, default=False)
confirmation_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
payment_method_type = Column(String(50), nullable=True)
refundable = Column(Boolean, default=False)
test_mode = Column(Boolean, default=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
yookassa_created_at = Column(DateTime, nullable=True)
captured_at = Column(DateTime, nullable=True)
user = relationship("User", backref="yookassa_payments")
transaction = relationship("Transaction", backref="yookassa_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == "pending"
@property
def is_succeeded(self) -> bool:
return self.status == "succeeded" and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ["canceled", "failed"]
@property
def can_be_captured(self) -> bool:
return self.status == "waiting_for_capture"
def __repr__(self):
return f"<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
username = Column(String(255), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
status = Column(String(20), default=UserStatus.ACTIVE.value)
language = Column(String(5), default="ru")
balance_kopeks = Column(Integer, default=0)
used_promocodes = Column(Integer, default=0)
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
referral_code = Column(String(20), unique=True, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_activity = Column(DateTime, default=func.now())
remnawave_uuid = Column(String(255), nullable=True, unique=True)
broadcasts = relationship("BroadcastHistory", back_populates="admin")
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
subscription = relationship("Subscription", back_populates="user", uselist=False)
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
last_remnawave_sync = Column(DateTime, nullable=True)
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:
return self.balance_kopeks / 100
@property
def full_name(self) -> str:
parts = [self.first_name, self.last_name]
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
def add_balance(self, kopeks: int) -> None:
self.balance_kopeks += kopeks
def subtract_balance(self, kopeks: int) -> bool:
if self.balance_kopeks >= kopeks:
self.balance_kopeks -= kopeks
return True
return False
class Subscription(Base):
__tablename__ = "subscriptions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
is_trial = Column(Boolean, default=True)
start_date = Column(DateTime, default=func.now())
end_date = Column(DateTime, nullable=False)
traffic_limit_gb = Column(Integer, default=0)
traffic_used_gb = Column(Float, default=0.0)
subscription_url = Column(String, nullable=True)
device_limit = Column(Integer, default=1)
connected_squads = Column(JSON, default=list)
autopay_enabled = Column(Boolean, default=False)
autopay_days_before = Column(Integer, default=3)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
remnawave_short_uuid = Column(String(255), nullable=True)
user = relationship("User", back_populates="subscription")
@property
def is_active(self) -> bool:
current_time = datetime.utcnow()
return (
self.status == SubscriptionStatus.ACTIVE.value and
self.end_date > current_time
)
@property
def is_expired(self) -> bool:
"""Проверяет, истёк ли срок подписки"""
return self.end_date <= datetime.utcnow()
@property
def should_be_expired(self) -> bool:
current_time = datetime.utcnow()
return (
self.status == SubscriptionStatus.ACTIVE.value and
self.end_date <= current_time
)
@property
def actual_status(self) -> str:
current_time = datetime.utcnow()
if self.status == SubscriptionStatus.EXPIRED.value:
return "expired"
if self.status == SubscriptionStatus.DISABLED.value:
return "disabled"
if self.status == SubscriptionStatus.ACTIVE.value:
if self.end_date <= current_time:
return "expired"
else:
return "active"
if self.status == SubscriptionStatus.TRIAL.value:
if self.end_date <= current_time:
return "expired"
else:
return "trial"
return self.status
@property
def status_display(self) -> str:
actual_status = self.actual_status
current_time = datetime.utcnow()
if actual_status == "expired":
return "🔴 Истекла"
elif actual_status == "active":
if self.is_trial:
return "🎯 Тестовая"
else:
return "🟢 Активна"
elif actual_status == "disabled":
return "⚫ Отключена"
elif actual_status == "trial":
return "🎯 Тестовая"
return "❓ Неизвестно"
@property
def status_emoji(self) -> str:
actual_status = self.actual_status
if actual_status == "expired":
return "🔴"
elif actual_status == "active":
if self.is_trial:
return "🎁"
else:
return "💎"
elif actual_status == "disabled":
return ""
elif actual_status == "trial":
return "🎁"
return ""
@property
def days_left(self) -> int:
current_time = datetime.utcnow()
if self.end_date <= current_time:
return 0
delta = self.end_date - current_time
return max(0, delta.days)
@property
def time_left_display(self) -> str:
current_time = datetime.utcnow()
if self.end_date <= current_time:
return "истёк"
delta = self.end_date - current_time
days = delta.days
hours = delta.seconds // 3600
minutes = (delta.seconds % 3600) // 60
if days > 0:
return f"{days} дн."
elif hours > 0:
return f"{hours} ч."
else:
return f"{minutes} мин."
@property
def traffic_used_percent(self) -> float:
if self.traffic_limit_gb == 0:
return 0.0
if self.traffic_limit_gb > 0:
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
return 0.0
def extend_subscription(self, days: int):
from datetime import timedelta, datetime
if self.end_date > datetime.utcnow():
self.end_date = self.end_date + timedelta(days=days)
else:
self.end_date = datetime.utcnow() + timedelta(days=days)
if self.status == SubscriptionStatus.EXPIRED.value:
self.status = SubscriptionStatus.ACTIVE.value
def add_traffic(self, gb: int):
if self.traffic_limit_gb == 0:
return
self.traffic_limit_gb += gb
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
type = Column(String(50), nullable=False)
amount_kopeks = Column(Integer, nullable=False)
description = Column(Text, nullable=True)
payment_method = Column(String(50), nullable=True)
external_id = Column(String(255), nullable=True)
is_completed = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
completed_at = Column(DateTime, nullable=True)
user = relationship("User", back_populates="transactions")
@property
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"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(50), unique=True, nullable=False, index=True)
type = Column(String(50), nullable=False)
balance_bonus_kopeks = Column(Integer, default=0)
subscription_days = Column(Integer, default=0)
max_uses = Column(Integer, default=1)
current_uses = Column(Integer, default=0)
valid_from = Column(DateTime, default=func.now())
valid_until = Column(DateTime, nullable=True)
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())
uses = relationship("PromoCodeUse", back_populates="promocode")
@property
def is_valid(self) -> bool:
now = datetime.utcnow()
return (
self.is_active and
self.current_uses < self.max_uses and
self.valid_from <= now and
(self.valid_until is None or self.valid_until >= now)
)
@property
def uses_left(self) -> int:
return max(0, self.max_uses - self.current_uses)
class PromoCodeUse(Base):
__tablename__ = "promocode_uses"
id = Column(Integer, primary_key=True, index=True)
promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
used_at = Column(DateTime, default=func.now())
promocode = relationship("PromoCode", back_populates="uses")
user = relationship("User")
class ReferralEarning(Base):
__tablename__ = "referral_earnings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
referral_id = Column(Integer, ForeignKey("users.id"), nullable=False)
amount_kopeks = Column(Integer, nullable=False)
reason = Column(String(100), nullable=False)
referral_transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", foreign_keys=[user_id], back_populates="referral_earnings")
referral = relationship("User", foreign_keys=[referral_id])
referral_transaction = relationship("Transaction")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
class Squad(Base):
__tablename__ = "squads"
id = Column(Integer, primary_key=True, index=True)
uuid = Column(String(255), unique=True, nullable=False)
name = Column(String(255), nullable=False)
country_code = Column(String(5), nullable=True)
is_available = Column(Boolean, default=True)
price_kopeks = Column(Integer, default=0)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
@property
def price_rubles(self) -> float:
return self.price_kopeks / 100
class ServiceRule(Base):
__tablename__ = "service_rules"
id = Column(Integer, primary_key=True, index=True)
order = Column(Integer, default=0)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
is_active = Column(Boolean, default=True)
language = Column(String(5), default="ru")
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class SystemSetting(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(255), unique=True, nullable=False)
value = Column(Text, nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class MonitoringLog(Base):
__tablename__ = "monitoring_logs"
id = Column(Integer, primary_key=True, index=True)
event_type = Column(String(100), nullable=False)
message = Column(Text, nullable=False)
data = Column(JSON, nullable=True)
is_success = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
id = Column(Integer, primary_key=True, index=True)
target_type = Column(String(100), nullable=False)
message_text = Column(Text, nullable=False)
total_count = Column(Integer, default=0)
sent_count = Column(Integer, default=0)
failed_count = Column(Integer, default=0)
status = Column(String(50), default="in_progress")
admin_id = Column(Integer, ForeignKey("users.id"))
admin_name = Column(String(255))
created_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True), nullable=True)
admin = relationship("User", back_populates="broadcasts")
class ServerSquad(Base):
__tablename__ = "server_squads"
id = Column(Integer, primary_key=True, index=True)
squad_uuid = Column(String(255), unique=True, nullable=False, index=True)
display_name = Column(String(255), nullable=False)
original_name = Column(String(255), nullable=True)
country_code = Column(String(5), nullable=True)
is_available = Column(Boolean, default=True)
price_kopeks = Column(Integer, default=0)
description = Column(Text, nullable=True)
sort_order = Column(Integer, default=0)
max_users = Column(Integer, nullable=True)
current_users = Column(Integer, default=0)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
@property
def price_rubles(self) -> float:
return self.price_kopeks / 100
@property
def is_full(self) -> bool:
if self.max_users is None:
return False
return self.current_users >= self.max_users
@property
def availability_status(self) -> str:
if not self.is_available:
return "Недоступен"
elif self.is_full:
return "Переполнен"
else:
return "Доступен"
class SubscriptionServer(Base):
__tablename__ = "subscription_servers"
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False)
server_squad_id = Column(Integer, ForeignKey("server_squads.id"), nullable=False)
connected_at = Column(DateTime, default=func.now())
paid_price_kopeks = Column(Integer, default=0)
subscription = relationship("Subscription", backref="subscription_servers")
server_squad = relationship("ServerSquad", backref="subscription_servers")
+594
View File
@@ -0,0 +1,594 @@
import logging
from sqlalchemy import text, inspect
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import engine
logger = logging.getLogger(__name__)
async def get_database_type():
return engine.dialect.name
async def check_table_exists(table_name: str) -> bool:
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
result = await conn.execute(text(f"""
SELECT name FROM sqlite_master
WHERE type='table' AND name='{table_name}'
"""))
return result.fetchone() is not None
elif db_type == 'postgresql':
result = await conn.execute(text("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = :table_name
"""), {"table_name": table_name})
return result.fetchone() is not None
elif db_type == 'mysql':
result = await conn.execute(text("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = :table_name
"""), {"table_name": table_name})
return result.fetchone() is not None
return False
except Exception as e:
logger.error(f"Ошибка проверки существования таблицы {table_name}: {e}")
return False
async def check_column_exists(table_name: str, column_name: str) -> bool:
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
result = await conn.execute(text(f"PRAGMA table_info({table_name})"))
columns = result.fetchall()
return any(col[1] == column_name for col in columns)
elif db_type == 'postgresql':
result = await conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = :table_name
AND column_name = :column_name
"""), {"table_name": table_name, "column_name": column_name})
return result.fetchone() is not None
elif db_type == 'mysql':
result = await conn.execute(text("""
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name
"""), {"table_name": table_name, "column_name": column_name})
return result.fetchone() is not None
return False
except Exception as e:
logger.error(f"Ошибка проверки существования колонки {column_name}: {e}")
return False
async def create_yookassa_payments_table():
table_exists = await check_table_exists('yookassa_payments')
if table_exists:
logger.info("Таблица yookassa_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE yookassa_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,
status VARCHAR(50) NOT NULL,
is_paid BOOLEAN DEFAULT 0,
is_captured BOOLEAN DEFAULT 0,
confirmation_url TEXT NULL,
metadata_json TEXT 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);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE yookassa_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,
status VARCHAR(50) NOT NULL,
is_paid BOOLEAN DEFAULT FALSE,
is_captured BOOLEAN DEFAULT FALSE,
confirmation_url TEXT NULL,
metadata_json JSONB 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);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE yookassa_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,
status VARCHAR(50) NOT NULL,
is_paid BOOLEAN DEFAULT FALSE,
is_captured BOOLEAN DEFAULT FALSE,
confirmation_url TEXT NULL,
metadata_json JSON 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);
"""
else:
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица yookassa_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы yookassa_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 ===")
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 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")
else:
logger.info("Все колонки RemnaWave v2.1.5 уже существуют")
return columns_added
except Exception as e:
logger.error(f"Ошибка при добавлении колонок RemnaWave v2.1.5: {e}")
return 0
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}")
try:
result = await conn.execute(text("""
SELECT user_id, COUNT(*) as count
FROM subscriptions
GROUP BY user_id
HAVING COUNT(*) > 1
"""))
duplicates = result.fetchall()
if not duplicates:
logger.info("Дублирующихся подписок не найдено")
return 0
logger.info(f"Найдено {len(duplicates)} пользователей с дублирующимися подписками")
total_deleted = 0
for user_id_row, count in duplicates:
user_id = user_id_row
if db_type == 'sqlite':
delete_result = await conn.execute(text("""
DELETE FROM subscriptions
WHERE user_id = :user_id AND id NOT IN (
SELECT MAX(id)
FROM subscriptions
WHERE user_id = :user_id
)
"""), {"user_id": user_id})
elif db_type in ['postgresql', 'mysql']:
delete_result = await conn.execute(text("""
DELETE FROM subscriptions
WHERE user_id = :user_id AND id NOT IN (
SELECT max_id FROM (
SELECT MAX(id) as max_id
FROM subscriptions
WHERE user_id = :user_id
) as subquery
)
"""), {"user_id": user_id})
else:
subs_result = await conn.execute(text("""
SELECT id FROM subscriptions
WHERE user_id = :user_id
ORDER BY created_at DESC, id DESC
"""), {"user_id": user_id})
sub_ids = [row[0] for row in subs_result.fetchall()]
if len(sub_ids) > 1:
ids_to_delete = sub_ids[1:]
for sub_id in ids_to_delete:
await conn.execute(text("""
DELETE FROM subscriptions WHERE id = :id
"""), {"id": sub_id})
delete_result = type('Result', (), {'rowcount': len(ids_to_delete)})()
else:
delete_result = type('Result', (), {'rowcount': 0})()
deleted_count = delete_result.rowcount
total_deleted += deleted_count
logger.info(f"Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}")
logger.info(f"Всего удалено дублирующихся подписок: {total_deleted}")
return total_deleted
except Exception as e:
logger.error(f"Ошибка при очистке дублирующихся подписок: {e}")
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 готова")
else:
logger.warning("⚠️ Проблемы с таблицей YooKassa payments")
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"))
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
total_count = total_subs.fetchone()[0]
unique_count = unique_users.fetchone()[0]
logger.info(f"Всего подписок: {total_count}")
logger.info(f"Уникальных пользователей: {unique_count}")
if total_count == unique_count:
logger.info("База данных уже в корректном состоянии")
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
return True
deleted_count = await fix_subscription_duplicates_universal()
async with engine.begin() as conn:
final_check = await conn.execute(text("""
SELECT user_id, COUNT(*) as count
FROM subscriptions
GROUP BY user_id
HAVING COUNT(*) > 1
"""))
remaining_duplicates = final_check.fetchall()
if remaining_duplicates:
logger.warning(f"Остались дубликаты у {len(remaining_duplicates)} пользователей")
return False
else:
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
logger.info("✅ Реферальная система обновлена")
logger.info("✅ RemnaWave v2.1.5 колонки добавлены")
logger.info("✅ YooKassa таблица готова")
logger.info("✅ Таблица конверсий подписок создана")
logger.info("✅ Дубликаты подписок исправлены")
return True
except Exception as e:
logger.error(f"=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===")
return False
async def check_migration_status():
logger.info("=== ПРОВЕРКА СТАТУСА МИГРАЦИЙ ===")
try:
status = {
"has_made_first_topup_column": False,
"yookassa_table": False,
"remnawave_v2_columns": False,
"subscription_duplicates": False,
"subscription_conversions_table": False
}
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
status["yookassa_table"] = await check_table_exists('yookassa_payments')
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
remnawave_columns = ['lifetime_used_traffic_bytes', 'last_remnawave_sync', 'trojan_password', 'vless_uuid', 'ss_password']
remnawave_status = []
for col in remnawave_columns:
exists = await check_column_exists('users', col)
remnawave_status.append(exists)
status["remnawave_v2_columns"] = all(remnawave_status)
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": "Колонка реферальной системы",
"yookassa_table": "Таблица YooKassa payments",
"subscription_conversions_table": "Таблица конверсий подписок",
"remnawave_v2_columns": "Колонки RemnaWave v2.1.5",
"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"))
conv_count = conversions_count.fetchone()[0]
usr_count = users_count.fetchone()[0]
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_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
+620
View File
@@ -0,0 +1,620 @@
import asyncio
import json
import ssl
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Union, Any
from urllib.parse import urlparse
import aiohttp
import logging
from dataclasses import dataclass
from enum import Enum
from urllib.parse import urlparse, urljoin
logger = logging.getLogger(__name__)
class UserStatus(Enum):
ACTIVE = "ACTIVE"
DISABLED = "DISABLED"
LIMITED = "LIMITED"
EXPIRED = "EXPIRED"
class TrafficLimitStrategy(Enum):
NO_RESET = "NO_RESET"
DAY = "DAY"
WEEK = "WEEK"
MONTH = "MONTH"
@dataclass
class RemnaWaveUser:
uuid: str
short_uuid: str
username: str
status: UserStatus
used_traffic_bytes: int
lifetime_used_traffic_bytes: int
traffic_limit_bytes: int
traffic_limit_strategy: TrafficLimitStrategy
expire_at: datetime
telegram_id: Optional[int]
email: Optional[str]
hwid_device_limit: Optional[int]
description: Optional[str]
tag: Optional[str]
subscription_url: str
active_internal_squads: List[Dict[str, str]]
created_at: datetime
updated_at: datetime
sub_last_user_agent: Optional[str] = None
sub_last_opened_at: Optional[datetime] = None
online_at: Optional[datetime] = None
sub_revoked_at: Optional[datetime] = None
last_traffic_reset_at: Optional[datetime] = None
trojan_password: Optional[str] = None
vless_uuid: Optional[str] = None
ss_password: Optional[str] = None
first_connected_at: Optional[datetime] = None
last_triggered_threshold: int = 0
@dataclass
class RemnaWaveInternalSquad:
uuid: str
name: str
members_count: int
inbounds_count: int
inbounds: List[Dict[str, Any]]
@dataclass
class RemnaWaveNode:
uuid: str
name: str
address: str
country_code: str
is_connected: bool
is_disabled: bool
is_node_online: bool
is_xray_running: bool
users_online: Optional[int]
traffic_used_bytes: Optional[int]
traffic_limit_bytes: Optional[int]
class RemnaWaveAPIError(Exception):
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
self.message = message
self.status_code = status_code
self.response_data = response_data
super().__init__(self.message)
class RemnaWaveAPI:
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None):
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.secret_key = secret_key
self.session: Optional[aiohttp.ClientSession] = None
self.authenticated = False
def _detect_connection_type(self) -> str:
parsed = urlparse(self.base_url)
local_hosts = [
'localhost', '127.0.0.1', 'remnawave',
'remnawave-backend', 'app', 'api'
]
if parsed.hostname in local_hosts:
return "local"
if parsed.hostname:
if (parsed.hostname.startswith('192.168.') or
parsed.hostname.startswith('10.') or
parsed.hostname.startswith('172.') or
parsed.hostname.endswith('.local')):
return "local"
return "external"
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.info(f"🔗 Подключение к RemnaWave: {self.base_url} (тип: {conn_type})")
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Forwarded-Proto': 'https',
'X-Forwarded-For': '127.0.0.1',
'X-Real-IP': '127.0.0.1'
}
cookies = None
if self.secret_key:
if ':' in self.secret_key:
key_name, key_value = self.secret_key.split(':', 1)
cookies = {key_name: key_value}
logger.debug(f"🍪 Используем куки: {key_name}=***")
else:
cookies = {self.secret_key: self.secret_key}
logger.debug(f"🍪 Используем куки: {self.secret_key}=***")
connector_kwargs = {}
if conn_type == "local":
logger.debug("🏠 Использую локальные заголовки proxy")
headers.update({
'X-Forwarded-Host': 'localhost',
'Host': 'localhost'
})
if self.base_url.startswith('https://'):
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector_kwargs['ssl'] = ssl_context
logger.debug("🔓 SSL проверка отключена для локального HTTPS")
elif conn_type == "external":
logger.debug("🌐 Использую внешнее подключение с полной SSL проверкой")
pass
connector = aiohttp.TCPConnector(**connector_kwargs)
session_kwargs = {
'timeout': aiohttp.ClientTimeout(total=30),
'headers': headers,
'connector': connector
}
if cookies:
session_kwargs['cookies'] = cookies
self.session = aiohttp.ClientSession(**session_kwargs)
self.authenticated = True
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.session:
await self.session.close()
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None,
params: Optional[Dict] = None
) -> Dict:
if not self.session:
raise RemnaWaveAPIError("Session not initialized. Use async context manager.")
url = f"{self.base_url}{endpoint}"
try:
kwargs = {
'url': url,
'params': params
}
if data:
kwargs['json'] = data
async with self.session.request(method, **kwargs) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
response_data = {'raw_response': response_text}
if response.status >= 400:
error_message = response_data.get('message', f'HTTP {response.status}')
logger.error(f"API Error {response.status}: {error_message}")
logger.error(f"Response: {response_text[:500]}")
raise RemnaWaveAPIError(
error_message,
response.status,
response_data
)
return response_data
except aiohttp.ClientError as e:
logger.error(f"Request failed: {e}")
raise RemnaWaveAPIError(f"Request failed: {str(e)}")
async def create_user(
self,
username: str,
expire_at: datetime,
status: UserStatus = UserStatus.ACTIVE,
traffic_limit_bytes: int = 0,
traffic_limit_strategy: TrafficLimitStrategy = TrafficLimitStrategy.NO_RESET,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
hwid_device_limit: Optional[int] = None,
description: Optional[str] = None,
tag: Optional[str] = None,
active_internal_squads: Optional[List[str]] = None
) -> RemnaWaveUser:
data = {
'username': username,
'status': status.value,
'expireAt': expire_at.isoformat(),
'trafficLimitBytes': traffic_limit_bytes,
'trafficLimitStrategy': traffic_limit_strategy.value
}
if telegram_id:
data['telegramId'] = telegram_id
if email:
data['email'] = email
if hwid_device_limit:
data['hwidDeviceLimit'] = hwid_device_limit
if description:
data['description'] = description
if tag:
data['tag'] = tag
if active_internal_squads:
data['activeInternalSquads'] = active_internal_squads
response = await self._make_request('POST', '/api/users', data)
return self._parse_user(response['response'])
async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/{uuid}')
return self._parse_user(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
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']]
except RemnaWaveAPIError as e:
if e.status_code == 404:
return []
raise
async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/by-username/{username}')
return self._parse_user(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def update_user(
self,
uuid: str,
status: Optional[UserStatus] = None,
traffic_limit_bytes: Optional[int] = None,
traffic_limit_strategy: Optional[TrafficLimitStrategy] = None,
expire_at: Optional[datetime] = None,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
hwid_device_limit: Optional[int] = None,
description: Optional[str] = None,
tag: Optional[str] = None,
active_internal_squads: Optional[List[str]] = None
) -> RemnaWaveUser:
data = {'uuid': uuid}
if status:
data['status'] = status.value
if traffic_limit_bytes is not None:
data['trafficLimitBytes'] = traffic_limit_bytes
if traffic_limit_strategy:
data['trafficLimitStrategy'] = traffic_limit_strategy.value
if expire_at:
data['expireAt'] = expire_at.isoformat()
if telegram_id is not None:
data['telegramId'] = telegram_id
if email is not None:
data['email'] = email
if hwid_device_limit is not None:
data['hwidDeviceLimit'] = hwid_device_limit
if description is not None:
data['description'] = description
if tag is not None:
data['tag'] = tag
if active_internal_squads is not None:
data['activeInternalSquads'] = active_internal_squads
response = await self._make_request('PATCH', '/api/users', data)
return self._parse_user(response['response'])
async def delete_user(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/users/{uuid}')
return response['response']['isDeleted']
async def enable_user(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable')
return self._parse_user(response['response'])
async def disable_user(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable')
return self._parse_user(response['response'])
async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic')
return self._parse_user(response['response'])
async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser:
data = {}
if new_short_uuid:
data['shortUuid'] = new_short_uuid
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
return self._parse_user(response['response'])
async def get_all_users(self, start: int = 0, size: int = 100) -> Dict[str, Any]:
params = {'start': start, 'size': size}
response = await self._make_request('GET', '/api/users', params=params)
return {
'users': [self._parse_user(user) for user in response['response']['users']],
'total': response['response']['total']
}
async def get_internal_squads(self) -> List[RemnaWaveInternalSquad]:
response = await self._make_request('GET', '/api/internal-squads')
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
async def get_internal_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveInternalSquad]:
try:
response = await self._make_request('GET', f'/api/internal-squads/{uuid}')
return self._parse_internal_squad(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def create_internal_squad(self, name: str, inbounds: List[str]) -> RemnaWaveInternalSquad:
data = {
'name': name,
'inbounds': inbounds
}
response = await self._make_request('POST', '/api/internal-squads', data)
return self._parse_internal_squad(response['response'])
async def update_internal_squad(
self,
uuid: str,
name: Optional[str] = None,
inbounds: Optional[List[str]] = None
) -> RemnaWaveInternalSquad:
data = {'uuid': uuid}
if name:
data['name'] = name
if inbounds is not None:
data['inbounds'] = inbounds
response = await self._make_request('PATCH', '/api/internal-squads', data)
return self._parse_internal_squad(response['response'])
async def delete_internal_squad(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
return response['response']['isDeleted']
async def get_all_nodes(self) -> List[RemnaWaveNode]:
response = await self._make_request('GET', '/api/nodes')
return [self._parse_node(node) for node in response['response']]
async def get_node_by_uuid(self, uuid: str) -> Optional[RemnaWaveNode]:
try:
response = await self._make_request('GET', f'/api/nodes/{uuid}')
return self._parse_node(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def enable_node(self, uuid: str) -> RemnaWaveNode:
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/enable')
return self._parse_node(response['response'])
async def disable_node(self, uuid: str) -> RemnaWaveNode:
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/disable')
return self._parse_node(response['response'])
async def restart_node(self, uuid: str) -> bool:
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart')
return response['response']['eventSent']
async def restart_all_nodes(self) -> bool:
response = await self._make_request('POST', '/api/nodes/actions/restart-all')
return response['response']['eventSent']
async def get_subscription_info(self, short_uuid: str) -> Dict[str, Any]:
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
return response['response']
async def get_system_stats(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats')
return response['response']
async def get_bandwidth_stats(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats/bandwidth')
return response['response']
async def get_nodes_statistics(self) -> Dict[str, Any]:
response = await self._make_request('GET', '/api/system/stats/nodes')
return response['response']
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
response = await self._make_request('GET', '/api/nodes/usage/realtime')
return response['response']
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
return response['response']
except RemnaWaveAPIError as e:
if e.status_code == 404:
return {'total': 0, 'devices': []}
raise
async def reset_user_devices(self, user_uuid: str) -> bool:
try:
devices_info = await self.get_user_devices(user_uuid)
devices = devices_info.get('devices', [])
if not devices:
return True
failed_count = 0
for device in devices:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
except Exception as device_error:
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
failed_count += 1
return failed_count < len(devices) / 2
except Exception as e:
logger.error(f"Ошибка при сбросе устройств: {e}")
return False
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
try:
delete_data = {
"userUuid": user_uuid,
"hwid": device_hwid
}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
return True
except Exception as e:
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
return False
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
return RemnaWaveUser(
uuid=user_data['uuid'],
short_uuid=user_data['shortUuid'],
username=user_data['username'],
status=UserStatus(user_data['status']),
used_traffic_bytes=int(user_data['usedTrafficBytes']),
lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']),
traffic_limit_bytes=user_data['trafficLimitBytes'],
traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']),
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
telegram_id=user_data.get('telegramId'),
email=user_data.get('email'),
hwid_device_limit=user_data.get('hwidDeviceLimit'),
description=user_data.get('description'),
tag=user_data.get('tag'),
subscription_url=user_data['subscriptionUrl'],
active_internal_squads=user_data['activeInternalSquads'],
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
sub_last_user_agent=user_data.get('subLastUserAgent'),
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
trojan_password=user_data.get('trojanPassword'),
vless_uuid=user_data.get('vlessUuid'),
ss_password=user_data.get('ssPassword'),
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
)
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
if date_str:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return None
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
return RemnaWaveInternalSquad(
uuid=squad_data['uuid'],
name=squad_data['name'],
members_count=squad_data['info']['membersCount'],
inbounds_count=squad_data['info']['inboundsCount'],
inbounds=squad_data['inbounds']
)
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
return RemnaWaveNode(
uuid=node_data['uuid'],
name=node_data['name'],
address=node_data['address'],
country_code=node_data['countryCode'],
is_connected=node_data['isConnected'],
is_disabled=node_data['isDisabled'],
is_node_online=node_data['isNodeOnline'],
is_xray_running=node_data['isXrayRunning'],
users_online=node_data.get('usersOnline'),
traffic_used_bytes=node_data.get('trafficUsedBytes'),
traffic_limit_bytes=node_data.get('trafficLimitBytes')
)
def format_bytes(bytes_value: int) -> str:
if bytes_value == 0:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
size = bytes_value
unit_index = 0
while size >= 1024 and unit_index < len(units) - 1:
size /= 1024
unit_index += 1
return f"{size:.1f} {units[unit_index]}"
def parse_bytes(size_str: str) -> int:
size_str = size_str.upper().strip()
units = {
'B': 1,
'KB': 1024,
'MB': 1024 ** 2,
'GB': 1024 ** 3,
'TB': 1024 ** 4
}
for unit, multiplier in units.items():
if size_str.endswith(unit):
try:
value = float(size_str[:-len(unit)].strip())
return int(value * multiplier)
except ValueError:
break
return 0
async def test_api_connection(api: RemnaWaveAPI) -> bool:
try:
await api.get_system_stats()
return True
except Exception as e:
logger.error(f"API connection test failed: {e}")
return False
+112
View File
@@ -0,0 +1,112 @@
import logging
from typing import Optional, Dict, Any
from aiogram import Bot
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
from app.config import settings
logger = logging.getLogger(__name__)
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,
title: str,
description: str,
amount_kopeks: int,
payload: str,
start_parameter: Optional[str] = None
) -> Optional[str]:
try:
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
invoice_link = await self.bot.create_invoice_link(
title=title,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=[LabeledPrice(label=title, amount=stars_amount)],
start_parameter=start_parameter
)
logger.info(
f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽) "
f"для {chat_id}, курс: {settings.get_stars_rate():.2f}₽/⭐"
)
return invoice_link
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
return None
async def send_invoice(
self,
chat_id: int,
title: str,
description: str,
amount_kopeks: int,
payload: str,
keyboard: Optional[InlineKeyboardMarkup] = None
) -> Optional[Dict[str, Any]]:
try:
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
message = await self.bot.send_invoice(
chat_id=chat_id,
title=title,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=[LabeledPrice(label=title, amount=stars_amount)],
reply_markup=keyboard
)
logger.info(
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
f"(~{amount_rubles:.2f}₽), курс: {settings.get_stars_rate():.2f}₽/⭐"
)
return {
"message_id": message.message_id,
"stars_amount": stars_amount,
"rubles_amount": amount_rubles,
"payload": payload
}
except Exception as e:
logger.error(f"Ошибка отправки Stars invoice: {e}")
return None
async def answer_pre_checkout_query(
self,
pre_checkout_query_id: str,
ok: bool = True,
error_message: Optional[str] = None
) -> bool:
try:
await self.bot.answer_pre_checkout_query(
pre_checkout_query_id=pre_checkout_query_id,
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
+162
View File
@@ -0,0 +1,162 @@
import logging
import hashlib
import hmac
import json
from typing import Optional, Dict, Any
from app.config import settings
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(
self,
user_id: int,
amount_kopeks: int = 0,
description: str = "Пополнение баланса"
) -> Optional[str]:
if not settings.TRIBUTE_ENABLED:
logger.warning("Tribute платежи отключены")
return None
try:
payment_url = f"{self.donate_link}&user_id={user_id}"
logger.info(f"Создана ссылка Tribute для пользователя {user_id}")
return payment_url
except Exception as e:
logger.error(f"Ошибка создания Tribute ссылки: {e}")
return None
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
if not self.webhook_secret:
logger.warning("Webhook secret не настроен, пропускаем проверку")
return True
try:
expected_signature = hmac.new(
self.webhook_secret.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
async def process_webhook(self, payload_or_data) -> Optional[Dict[str, Any]]:
try:
logger.info(f"🔄 Начинаем обработку Tribute webhook")
if isinstance(payload_or_data, str):
try:
webhook_data = json.loads(payload_or_data)
logger.info(f"📊 Распарсенные данные: {webhook_data}")
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON: {e}")
return None
else:
webhook_data = payload_or_data
payment_id = None
status = None
amount_kopeks = 0
telegram_user_id = None
payment_id = webhook_data.get("id") or webhook_data.get("payment_id")
status = webhook_data.get("status")
amount_kopeks = webhook_data.get("amount", 0)
telegram_user_id = webhook_data.get("telegram_user_id") or webhook_data.get("user_id")
if not payment_id and "payload" in webhook_data:
data = webhook_data["payload"]
payment_id = data.get("id") or data.get("payment_id")
status = data.get("status")
amount_kopeks = data.get("amount", 0)
telegram_user_id = data.get("telegram_user_id") or data.get("user_id")
if not payment_id and "name" in webhook_data:
event_name = webhook_data.get("name")
data = webhook_data.get("payload", {})
payment_id = str(data.get("donation_request_id"))
amount_kopeks = data.get("amount", 0)
telegram_user_id = data.get("telegram_user_id")
if event_name == "new_donation":
status = "paid"
elif event_name == "cancelled_subscription":
status = "cancelled"
else:
status = "unknown"
logger.info(f"📝 Извлеченные данные: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}")
if not telegram_user_id:
logger.error("❌ Не найден telegram_user_id в webhook данных")
logger.error(f"🔍 Полные данные для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
return None
try:
telegram_user_id = int(telegram_user_id)
except (ValueError, TypeError):
logger.error(f"❌ Некорректный telegram_user_id: {telegram_user_id}")
return None
result = {
"event_type": "payment",
"payment_id": payment_id or f"tribute_{telegram_user_id}_{amount_kopeks}",
"user_id": telegram_user_id,
"amount_kopeks": int(amount_kopeks) if amount_kopeks else 0,
"status": status or "paid",
"external_id": f"donation_{payment_id or 'unknown'}",
"payment_system": "tribute"
}
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
return result
except Exception as e:
logger.error(f"❌ Ошибка обработки Tribute webhook: {e}", exc_info=True)
logger.error(f"🔍 Webhook data для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
return None
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
try:
logger.info(f"Запрос статуса платежа {payment_id}")
return {"status": "unknown", "payment_id": payment_id}
except Exception as e:
logger.error(f"Ошибка получения статуса платежа: {e}")
return None
async def refund_payment(
self,
payment_id: str,
amount_kopeks: Optional[int] = None,
reason: str = "Возврат по запросу"
) -> Optional[Dict[str, Any]]:
try:
logger.info(f"Создание возврата для платежа {payment_id}")
return {"refund_id": f"refund_{payment_id}", "status": "pending"}
except Exception as e:
logger.error(f"Ошибка создания возврата: {e}")
return None
+154
View File
@@ -0,0 +1,154 @@
import logging
import json
from typing import Optional
from aiohttp import web
from aiogram import Bot
from app.config import settings
from app.services.tribute_service import TributeService
logger = logging.getLogger(__name__)
class WebhookServer:
def __init__(self, bot: Bot):
self.bot = bot
self.app = None
self.runner = None
self.site = None
self.tribute_service = TributeService(bot)
async def create_app(self) -> web.Application:
self.app = web.Application()
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
self.app.router.add_get('/health', self._health_check)
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
logger.info(f"Webhook сервер настроен:")
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
logger.info(f" - Health check: GET /health")
return self.app
async def start(self):
try:
if not self.app:
await self.create_app()
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(
self.runner,
host='0.0.0.0',
port=settings.TRIBUTE_WEBHOOK_PORT
)
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}")
except Exception as e:
logger.error(f"❌ Ошибка запуска Tribute webhook сервера: {e}")
raise
async def stop(self):
try:
if self.site:
await self.site.stop()
logger.info("Tribute webhook сайт остановлен")
if self.runner:
await self.runner.cleanup()
logger.info("Tribute webhook runner очищен")
except Exception as e:
logger.error(f"Ошибка остановки Tribute webhook сервера: {e}")
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Tribute-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)}")
raw_body = await request.read()
if not raw_body:
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}")
try:
webhook_data = json.loads(payload)
logger.info(f"📊 Распарсенные данные: {webhook_data}")
except json.JSONDecodeError as 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:
from app.external.tribute import TributeService as TributeAPI
tribute_api = TributeAPI()
if not tribute_api.verify_webhook_signature(payload, signature):
logger.error("❌ Неверная подпись Tribute webhook")
return web.json_response(
{"status": "error", "reason": "invalid_signature"},
status=401
)
result = await self.tribute_service.process_webhook(payload, signature)
if result:
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
return web.json_response({"status": "ok", "result": result}, status=200)
else:
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)
return web.json_response(
{"status": "error", "reason": "internal_error", "message": str(e)},
status=500
)
async def _health_check(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
"service": "tribute-webhooks",
"tribute_enabled": settings.TRIBUTE_ENABLED,
"port": settings.TRIBUTE_WEBHOOK_PORT,
"path": settings.TRIBUTE_WEBHOOK_PATH
})
+243
View File
@@ -0,0 +1,243 @@
import asyncio
import logging
import json
import hashlib
import hmac
import base64
from typing import Optional, Dict, Any
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.payment_service import PaymentService
from app.database.database import get_db
logger = logging.getLogger(__name__)
class YooKassaWebhookHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
try:
signature_parts = signature.strip().split(' ')
if len(signature_parts) < 4:
logger.error(f"Неверный формат подписи YooKassa: {signature}")
return False
version = signature_parts[0]
payment_id = signature_parts[1]
timestamp = signature_parts[2]
received_signature = signature_parts[3]
if version != "v1":
logger.error(f"Неподдерживаемая версия подписи: {version}")
return False
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
expected_signature_1 = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
expected_signature_2 = hmac.new(
secret.encode('utf-8'),
signed_payload_2.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
signed_payload_3 = f"{timestamp}.{body}"
expected_signature_3 = hmac.new(
secret.encode('utf-8'),
signed_payload_3.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
logger.debug(f"Получена подпись: {received_signature}")
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
is_valid = (
hmac.compare_digest(received_signature, expected_signature_1_b64) or
hmac.compare_digest(received_signature, expected_signature_2_b64) or
hmac.compare_digest(received_signature, expected_signature_3_b64)
)
if is_valid:
logger.info("✅ Подпись YooKassa webhook проверена успешно")
else:
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
return False
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
async def handle_webhook(self, request: web.Request) -> web.Response:
try:
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
logger.info(f"📋 Headers: {dict(request.headers)}")
body = await request.text()
if not body:
logger.warning("⚠️ Получен пустой webhook от YooKassa")
return web.Response(status=400, text="Empty body")
logger.info(f"📄 Body: {body}")
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
logger.info(f"🔐 Получена подпись: {signature}")
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
else:
logger.info("✅ Подпись webhook проверена успешно")
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
else:
logger.info("ℹ️ Проверка подписи отключена")
try:
webhook_data = json.loads(body)
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
return web.Response(status=400, text="Invalid JSON")
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
event_type = webhook_data.get("event")
if not event_type:
logger.warning("⚠️ Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type")
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
return web.Response(status=200, text="OK")
async for db in get_db():
try:
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
if success:
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
return web.Response(status=200, text="OK")
else:
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
return web.Response(status=500, text="Processing error")
finally:
await db.close()
except Exception as e:
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
return web.Response(status=500, text="Internal server error")
def setup_routes(self, app: web.Application) -> None:
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
app.router.add_post(webhook_path, self.handle_webhook)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
async def _get_handler(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
"message": "YooKassa webhook endpoint is working",
"method": "GET",
"path": request.path,
"note": "Use POST method for actual webhooks"
})
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-YooKassa-Signature',
}
)
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
webhook_handler = YooKassaWebhookHandler(payment_service)
webhook_handler.setup_routes(app)
async def health_check(request):
return web.json_response({
"status": "ok",
"service": "yookassa_webhook",
"port": settings.YOOKASSA_WEBHOOK_PORT,
"path": settings.YOOKASSA_WEBHOOK_PATH,
"enabled": settings.is_yookassa_enabled()
})
app.router.add_get("/health", health_check)
return app
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_yookassa_enabled():
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
return
try:
app = create_yookassa_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host='0.0.0.0',
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info("🛑 YooKassa webhook сервер получил сигнал остановки")
finally:
await site.stop()
await runner.cleanup()
logger.info("✅ YooKassa webhook сервер остановлен")
except Exception as e:
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
raise
View File
+1
View File
@@ -0,0 +1 @@
# Инициализация админских обработчиков
+37
View File
@@ -0,0 +1,37 @@
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.admin import get_admin_main_keyboard
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_admin_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
admin_text = texts.ADMIN_PANEL
await callback.message.edit_text(
admin_text,
reply_markup=get_admin_main_keyboard(db_user.language)
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_admin_panel,
F.data == "admin_panel"
)
+235
View File
@@ -0,0 +1,235 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.maintenance_service import maintenance_service
from app.keyboards.admin import get_maintenance_keyboard, get_admin_main_keyboard
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
class MaintenanceStates(StatesGroup):
waiting_for_reason = State()
@admin_required
@error_handler
async def show_maintenance_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
texts = get_texts(db_user.language)
status_info = maintenance_service.get_status_info()
status_emoji = "🔧" if status_info["is_active"] else ""
status_text = "Включен" if status_info["is_active"] else "Выключен"
api_emoji = "" if status_info["api_status"] else ""
api_text = "Доступно" if status_info["api_status"] else "Недоступно"
monitoring_emoji = "🔄" if status_info["monitoring_active"] else "⏹️"
monitoring_text = "Запущен" if status_info["monitoring_active"] else "Остановлен"
enabled_info = ""
if status_info["is_active"] and status_info["enabled_at"]:
enabled_time = status_info["enabled_at"].strftime("%d.%m.%Y %H:%M:%S")
enabled_info = f"\n📅 <b>Включен:</b> {enabled_time}"
if status_info["reason"]:
enabled_info += f"\n📝 <b>Причина:</b> {status_info['reason']}"
last_check_info = ""
if status_info["last_check"]:
last_check_time = status_info["last_check"].strftime("%H:%M:%S")
last_check_info = f"\n🕐 <b>Последняя проверка:</b> {last_check_time}"
failures_info = ""
if status_info["consecutive_failures"] > 0:
failures_info = f"\n⚠️ <b>Неудачных проверок подряд:</b> {status_info['consecutive_failures']}"
message_text = f"""
🔧 <b>Режим технических работ</b>
{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 'Отключено'}
{enabled_info}
{last_check_info}
{failures_info}
<i>В режиме техработ обычные пользователи не могут использовать бота. Администраторы имеют полный доступ.</i>
"""
await callback.message.edit_text(
message_text,
reply_markup=get_maintenance_keyboard(db_user.language, status_info["is_active"], status_info["monitoring_active"])
)
await callback.answer()
@admin_required
@error_handler
async def toggle_maintenance_mode(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
is_active = maintenance_service.is_maintenance_active()
if is_active:
success = await maintenance_service.disable_maintenance()
if success:
await callback.answer("Режим техработ выключен", show_alert=True)
else:
await callback.answer("Ошибка выключения режима техработ", show_alert=True)
else:
await state.set_state(MaintenanceStates.waiting_for_reason)
await callback.message.edit_text(
"🔧 <b>Включение режима техработ</b>\n\nВведите причину включения техработ или отправьте /skip для пропуска:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
])
)
await callback.answer()
@admin_required
@error_handler
async def process_maintenance_reason(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext
):
current_state = await state.get_state()
if current_state != MaintenanceStates.waiting_for_reason:
return
reason = None
if message.text and message.text != "/skip":
reason = message.text[:200]
success = await maintenance_service.enable_maintenance(reason=reason, auto=False)
if success:
response_text = "Режим техработ включен"
if reason:
response_text += f"\nПричина: {reason}"
else:
response_text = "Ошибка включения режима техработ"
await message.answer(response_text)
await state.clear()
status_info = maintenance_service.get_status_info()
await message.answer(
"Вернуться к панели управления техработами:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")]
])
)
@admin_required
@error_handler
async def toggle_monitoring(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
status_info = maintenance_service.get_status_info()
if status_info["monitoring_active"]:
success = await maintenance_service.stop_monitoring()
message = "Мониторинг остановлен" if success else "Ошибка остановки мониторинга"
else:
success = await maintenance_service.start_monitoring()
message = "Мониторинг запущен" if success else "Ошибка запуска мониторинга"
await callback.answer(message, show_alert=True)
await show_maintenance_panel(callback, db_user, db, None)
@admin_required
@error_handler
async def force_api_check(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.answer("Проверка API...", show_alert=False)
check_result = await maintenance_service.force_api_check()
if check_result["success"]:
status_text = "доступно" if check_result["api_available"] else "недоступно"
message = f"API {status_text}\nВремя ответа: {check_result['response_time']}с"
else:
message = f"Ошибка проверки: {check_result.get('error', 'Неизвестная ошибка')}"
await callback.message.answer(message)
await show_maintenance_panel(callback, db_user, db, None)
@admin_required
@error_handler
async def back_to_admin_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.ADMIN_PANEL,
reply_markup=get_admin_main_keyboard(db_user.language)
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_maintenance_panel,
F.data == "maintenance_panel"
)
dp.callback_query.register(
toggle_maintenance_mode,
F.data == "maintenance_toggle"
)
dp.callback_query.register(
toggle_monitoring,
F.data == "maintenance_monitoring"
)
dp.callback_query.register(
force_api_check,
F.data == "maintenance_check_api"
)
dp.callback_query.register(
back_to_admin_panel,
F.data == "admin_panel"
)
dp.message.register(
process_maintenance_reason,
MaintenanceStates.waiting_for_reason
)
+560
View File
@@ -0,0 +1,560 @@
import logging
import asyncio
from datetime import datetime, timedelta
from typing import Optional
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_
from app.config import settings
from app.states import AdminStates
from app.database.models import User, UserStatus, Subscription, BroadcastHistory
from app.keyboards.admin import (
get_admin_messages_keyboard, get_broadcast_target_keyboard,
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
get_admin_pagination_keyboard
)
from app.localization.texts import get_texts
from app.database.crud.user import get_users_list
from app.database.crud.subscription import get_expiring_subscriptions
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_messages_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = """
📨 <b>Управление рассылками</b>
Выберите тип рассылки:
- <b>Всем пользователям</b> - рассылка всем активным пользователям
- <b>По подпискам</b> - фильтрация по типу подписки
- <b>По критериям</b> - настраиваемые фильтры
- <b>История</b> - просмотр предыдущих рассылок
Будьте осторожны с массовыми рассылками!
"""
await callback.message.edit_text(
text,
reply_markup=get_admin_messages_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_broadcast_targets(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
await callback.message.edit_text(
"🎯 <b>Выбор целевой аудитории</b>\n\n"
"Выберите категорию пользователей для рассылки:",
reply_markup=get_broadcast_target_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_messages_history(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = 1
if '_page_' in callback.data:
page = int(callback.data.split('_page_')[1])
limit = 10
offset = (page - 1) * limit
stmt = select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(stmt)
broadcasts = result.scalars().all()
count_stmt = select(func.count(BroadcastHistory.id))
count_result = await db.execute(count_stmt)
total_count = count_result.scalar() or 0
total_pages = (total_count + limit - 1) // limit
if not broadcasts:
text = """
📋 <b>История рассылок</b>
История рассылок пуста.
Отправьте первую рассылку, чтобы увидеть её здесь.
"""
keyboard = [[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]]
else:
text = f"📋 <b>История рассылок</b> (страница {page}/{total_pages})\n\n"
for broadcast in broadcasts:
status_emoji = "" if broadcast.status == "completed" else "" if broadcast.status == "failed" else ""
success_rate = round((broadcast.sent_count / broadcast.total_count * 100), 1) if broadcast.total_count > 0 else 0
message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text
text += f"""
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
🎯 Аудитория: {get_target_name(broadcast.target_type)}
👤 Админ: {broadcast.admin_name}
📝 Сообщение: <i>{message_preview}</i>
"""
keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_custom_broadcast(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
stats = await get_users_statistics(db)
text = f"""
📝 <b>Рассылка по критериям</b>
📊 <b>Доступные фильтры:</b>
👥 <b>По регистрации:</b>
Сегодня: {stats['today']} чел.
За неделю: {stats['week']} чел.
За месяц: {stats['month']} чел.
💼 <b>По активности:</b>
Активные сегодня: {stats['active_today']} чел.
Неактивные 7+ дней: {stats['inactive_week']} чел.
Неактивные 30+ дней: {stats['inactive_month']} чел.
🔗 <b>По источнику:</b>
Через рефералов: {stats['referrals']} чел.
Прямая регистрация: {stats['direct']} чел.
Выберите критерий для фильтрации:
"""
await callback.message.edit_text(
text,
reply_markup=get_custom_criteria_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def select_custom_criteria(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
criteria = callback.data.replace('criteria_', '')
criteria_names = {
"today": "Зарегистрированные сегодня",
"week": "Зарегистрированные за неделю",
"month": "Зарегистрированные за месяц",
"active_today": "Активные сегодня",
"inactive_week": "Неактивные 7+ дней",
"inactive_month": "Неактивные 30+ дней",
"referrals": "Пришедшие через рефералов",
"direct": "Прямая регистрация"
}
user_count = await get_custom_users_count(db, criteria)
await state.update_data(broadcast_target=f"custom_{criteria}")
await callback.message.edit_text(
f"📨 <b>Создание рассылки</b>\n\n"
f"🎯 <b>Критерий:</b> {criteria_names.get(criteria, criteria)}\n"
f"👥 <b>Получателей:</b> {user_count}\n\n"
f"Введите текст сообщения для рассылки:\n\n"
f"<i>Поддерживается HTML разметка</i>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
]),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_broadcast_message)
await callback.answer()
@admin_required
@error_handler
async def select_broadcast_target(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
target = callback.data.split('_')[-1]
target_names = {
"all": "Всем пользователям",
"active": "С активной подпиской",
"trial": "С триальной подпиской",
"no": "Без подписки",
"expiring": "С истекающей подпиской"
}
user_count = await get_target_users_count(db, target)
await state.update_data(broadcast_target=target)
await callback.message.edit_text(
f"📨 <b>Создание рассылки</b>\n\n"
f"🎯 <b>Аудитория:</b> {target_names.get(target, target)}\n"
f"👥 <b>Получателей:</b> {user_count}\n\n"
f"Введите текст сообщения для рассылки:\n\n"
f"<i>Поддерживается HTML разметка</i>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
]),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_broadcast_message)
await callback.answer()
@admin_required
@error_handler
async def process_broadcast_message(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
broadcast_text = message.text
if len(broadcast_text) > 4000:
await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)")
return
data = await state.get_data()
target = data.get('broadcast_target')
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
await state.update_data(broadcast_message=broadcast_text)
target_display = get_target_display_name(target)
preview_text = f"""
📨 <b>Предварительный просмотр рассылки</b>
🎯 <b>Аудитория:</b> {target_display}
👥 <b>Получателей:</b> {user_count}
📝 <b>Сообщение:</b>
{broadcast_text}
Подтвердить отправку?
"""
keyboard = [
[
types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"),
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
]
await message.answer(
preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.set_state(AdminStates.confirming_broadcast)
@admin_required
@error_handler
async def confirm_broadcast(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
target = data.get('broadcast_target')
message_text = data.get('broadcast_message')
await callback.message.edit_text(
"📨 Начинаю рассылку...\n\n"
"⏳ Это может занять несколько минут.",
reply_markup=None,
parse_mode="HTML"
)
if target.startswith('custom_'):
users = await get_custom_users(db, target.replace('custom_', ''))
else:
users = await get_target_users(db, target)
broadcast_history = BroadcastHistory(
target_type=target,
message_text=message_text,
total_count=len(users),
sent_count=0,
failed_count=0,
admin_id=db_user.id,
admin_name=db_user.full_name,
status="in_progress"
)
db.add(broadcast_history)
await db.commit()
await db.refresh(broadcast_history)
sent_count = 0
failed_count = 0
for user in users:
try:
await callback.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML"
)
sent_count += 1
if sent_count % 20 == 0:
await asyncio.sleep(1)
except Exception as e:
failed_count += 1
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.status = "completed" if failed_count == 0 else "partial"
broadcast_history.completed_at = datetime.utcnow()
await db.commit()
result_text = f"""
<b>Рассылка завершена!</b>
📊 <b>Результат:</b>
- Отправлено: {sent_count}
- Не доставлено: {failed_count}
- Всего пользователей: {len(users)}
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%
<b>Администратор:</b> {db_user.full_name}
"""
await callback.message.edit_text(
result_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")]
]),
parse_mode="HTML"
)
await state.clear()
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)}")
async def get_target_users_count(db: AsyncSession, target: str) -> int:
users = await get_target_users(db, target)
return len(users)
async def get_target_users(db: AsyncSession, target: str) -> list:
if target == "all":
return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
elif target == "active":
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial]
elif target == "trial":
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
return [user for user in users if user.subscription and user.subscription.is_trial]
elif target == "no":
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
return [user for user in users if not user.subscription or not user.subscription.is_active]
elif target == "expiring":
expiring_subs = await get_expiring_subscriptions(db, 3)
return [sub.user for sub in expiring_subs if sub.user]
else:
return []
async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
users = await get_custom_users(db, criteria)
return len(users)
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
"""Получение пользователей по настраиваемым критериям"""
now = datetime.utcnow()
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
if criteria == "today":
stmt = select(User).where(
and_(User.status == "active", User.created_at >= today)
)
elif criteria == "week":
stmt = select(User).where(
and_(User.status == "active", User.created_at >= week_ago)
)
elif criteria == "month":
stmt = select(User).where(
and_(User.status == "active", User.created_at >= month_ago)
)
elif criteria == "active_today":
stmt = select(User).where(
and_(User.status == "active", User.last_activity >= today)
)
elif criteria == "inactive_week":
stmt = select(User).where(
and_(User.status == "active", User.last_activity < week_ago)
)
elif criteria == "inactive_month":
stmt = select(User).where(
and_(User.status == "active", User.last_activity < month_ago)
)
elif criteria == "referrals":
stmt = select(User).where(
and_(User.status == "active", User.referred_by_id.isnot(None))
)
elif criteria == "direct":
stmt = select(User).where(
and_(
User.status == "active",
User.referred_by_id.is_(None)
)
)
else:
return []
result = await db.execute(stmt)
return result.scalars().all()
async def get_users_statistics(db: AsyncSession) -> dict:
"""Получение статистики пользователей для отображения"""
now = datetime.utcnow()
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
stats = {}
stats['today'] = await db.scalar(
select(func.count(User.id)).where(
and_(User.status == "active", User.created_at >= today)
)
) or 0
stats['week'] = await db.scalar(
select(func.count(User.id)).where(
and_(User.status == "active", User.created_at >= week_ago)
)
) or 0
stats['month'] = await db.scalar(
select(func.count(User.id)).where(
and_(User.status == "active", User.created_at >= month_ago)
)
) or 0
stats['active_today'] = await db.scalar(
select(func.count(User.id)).where(
and_(User.status == "active", User.last_activity >= today)
)
) or 0
stats['inactive_week'] = await db.scalar(
select(func.count(User.id)).where(
and_(User.status == "active", User.last_activity < week_ago)
)
) or 0
stats['inactive_month'] = await db.scalar(
select(func.count(User.id)).where(
and_(User.status == "active", User.last_activity < month_ago)
)
) or 0
stats['referrals'] = await db.scalar(
select(func.count(User.id)).where(
and_(User.status == "active", User.referred_by_id.isnot(None))
)
) or 0
stats['direct'] = await db.scalar(
select(func.count(User.id)).where(
and_(
User.status == "active",
User.referred_by_id.is_(None)
)
)
) or 0
return stats
def get_target_name(target_type: str) -> str:
names = {
"all": "Всем пользователям",
"active": "С активной подпиской",
"trial": "С триальной подпиской",
"no": "Без подписки",
"expiring": "С истекающей подпиской",
"custom_today": "Зарегистрированные сегодня",
"custom_week": "Зарегистрированные за неделю",
"custom_month": "Зарегистрированные за месяц",
"custom_active_today": "Активные сегодня",
"custom_inactive_week": "Неактивные 7+ дней",
"custom_inactive_month": "Неактивные 30+ дней",
"custom_referrals": "Через рефералов",
"custom_direct": "Прямая регистрация"
}
return names.get(target_type, target_type)
def get_target_display_name(target: str) -> str:
return get_target_name(target)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_messages_menu, F.data == "admin_messages")
dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"]))
dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_"))
dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast")
dp.callback_query.register(show_messages_history, F.data.startswith("admin_msg_history"))
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
+313
View File
@@ -0,0 +1,313 @@
import asyncio
import logging
from datetime import datetime, timedelta
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command
from app.config import settings
from app.database.database import get_db
from app.services.monitoring_service import monitoring_service
from app.utils.decorators import admin_required
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
router = Router()
@router.callback_query(F.data == "admin_monitoring")
@admin_required
async def admin_monitoring_menu(callback: CallbackQuery):
"""Главное меню мониторинга"""
try:
async for db in get_db():
status = await monitoring_service.get_monitoring_status(db)
running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен"
last_update = status['last_update'].strftime('%H:%M:%S') if status['last_update'] else "Никогда"
text = f"""
🔍 <b>Система мониторинга</b>
📊 <b>Статус:</b> {running_status}
🕐 <b>Последнее обновление:</b> {last_update}
<b>Интервал проверки:</b> {settings.MONITORING_INTERVAL} мин
📈 <b>Статистика за 24 часа:</b>
Всего событий: {status['stats_24h']['total_events']}
Успешных: {status['stats_24h']['successful']}
Ошибок: {status['stats_24h']['failed']}
Успешность: {status['stats_24h']['success_rate']}%
🔧 Выберите действие:
"""
keyboard = get_monitoring_keyboard()
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
except Exception as e:
logger.error(f"Ошибка в админ меню мониторинга: {e}")
await callback.answer("❌ Ошибка получения данных", show_alert=True)
@router.callback_query(F.data == "admin_mon_start")
@admin_required
async def start_monitoring_callback(callback: CallbackQuery):
try:
if monitoring_service.is_running:
await callback.answer("ℹ️ Мониторинг уже запущен")
return
if not monitoring_service.bot:
monitoring_service.bot = callback.bot
asyncio.create_task(monitoring_service.start_monitoring())
await callback.answer("✅ Мониторинг запущен!")
await admin_monitoring_menu(callback)
except Exception as e:
logger.error(f"Ошибка запуска мониторинга: {e}")
await callback.answer(f"❌ Ошибка запуска: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_stop")
@admin_required
async def stop_monitoring_callback(callback: CallbackQuery):
try:
if not monitoring_service.is_running:
await callback.answer("ℹ️ Мониторинг уже остановлен")
return
monitoring_service.stop_monitoring()
await callback.answer("⏹️ Мониторинг остановлен!")
await admin_monitoring_menu(callback)
except Exception as e:
logger.error(f"Ошибка остановки мониторинга: {e}")
await callback.answer(f"❌ Ошибка остановки: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_force_check")
@admin_required
async def force_check_callback(callback: CallbackQuery):
try:
await callback.answer("⏳ Выполняем проверку подписок...")
async for db in get_db():
results = await monitoring_service.force_check_subscriptions(db)
text = f"""
<b>Принудительная проверка завершена</b>
📊 <b>Результаты проверки:</b>
Истекших подписок: {results['expired']}
Истекающих подписок: {results['expiring']}
Готовых к автооплате: {results['autopay_ready']}
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
Нажмите "Назад" для возврата в меню мониторинга.
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
except Exception as e:
logger.error(f"Ошибка принудительной проверки: {e}")
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_logs")
@admin_required
async def monitoring_logs_callback(callback: CallbackQuery):
try:
async for db in get_db():
logs = await monitoring_service.get_monitoring_logs(db, limit=15)
if not logs:
text = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
else:
text = "📝 <b>Последние логи мониторинга:</b>\n\n"
for log in logs:
icon = "" if log['is_success'] else ""
time_str = log['created_at'].strftime('%m-%d %H:%M')
event_type = log['event_type'].replace('_', ' ').title()
text += f"{icon} <code>{time_str}</code> {event_type}\n"
message = log['message']
if len(message) > 60:
message = message[:60] + "..."
text += f" 📄 {message}\n\n"
if len(text) > 3500:
text += "...\n\n<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
break
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
except Exception as e:
logger.error(f"Ошибка получения логов: {e}")
await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_clear_logs")
@admin_required
async def clear_logs_callback(callback: CallbackQuery):
try:
async for db in get_db():
deleted_count = await monitoring_service.cleanup_old_logs(db, days=7)
if deleted_count > 0:
await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов")
else:
await callback.answer("ℹ️ Нет старых логов для удаления")
await monitoring_logs_callback(callback)
break
except Exception as e:
logger.error(f"Ошибка очистки логов: {e}")
await callback.answer(f"❌ Ошибка очистки: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_test_notifications")
@admin_required
async def test_notifications_callback(callback: CallbackQuery):
"""Тест системы уведомлений"""
try:
test_message = f"""
🧪 <b>Тестовое уведомление системы мониторинга</b>
Это тестовое сообщение для проверки работы системы уведомлений.
📊 <b>Статус системы:</b>
Мониторинг: {'🟢 Работает' if monitoring_service.is_running else '🔴 Остановлен'}
Уведомления: {'🟢 Включены' if settings.ENABLE_NOTIFICATIONS else '🔴 Отключены'}
Время теста: {datetime.now().strftime('%H:%M:%S %d.%m.%Y')}
Если вы получили это сообщение, система уведомлений работает корректно!
"""
await callback.bot.send_message(
callback.from_user.id,
test_message,
parse_mode="HTML"
)
await callback.answer("✅ Тестовое уведомление отправлено!")
except Exception as e:
logger.error(f"Ошибка отправки тестового уведомления: {e}")
await callback.answer(f"❌ Ошибка отправки: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_statistics")
@admin_required
async def monitoring_statistics_callback(callback: CallbackQuery):
try:
async for db in get_db():
from app.database.crud.subscription import get_subscriptions_statistics
sub_stats = await get_subscriptions_statistics(db)
mon_status = await monitoring_service.get_monitoring_status(db)
week_ago = datetime.now() - timedelta(days=7)
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
week_success = sum(1 for log in week_logs if log['is_success'])
week_errors = len(week_logs) - week_success
text = f"""
📊 <b>Статистика мониторинга</b>
📱 <b>Подписки:</b>
Всего: {sub_stats['total_subscriptions']}
Активных: {sub_stats['active_subscriptions']}
Тестовых: {sub_stats['trial_subscriptions']}
Платных: {sub_stats['paid_subscriptions']}
📈 <b>За сегодня:</b>
Успешных операций: {mon_status['stats_24h']['successful']}
Ошибок: {mon_status['stats_24h']['failed']}
Успешность: {mon_status['stats_24h']['success_rate']}%
📊 <b>За неделю:</b>
Всего событий: {len(week_logs)}
Успешных: {week_success}
Ошибок: {week_errors}
Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
🔧 <b>Система:</b>
Интервал: {settings.MONITORING_INTERVAL} мин
Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
except Exception as e:
logger.error(f"Ошибка получения статистики: {e}")
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
@router.message(Command("monitoring"))
@admin_required
async def monitoring_command(message: Message):
"""Команда /monitoring для быстрого доступа"""
try:
async for db in get_db():
status = await monitoring_service.get_monitoring_status(db)
running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен"
text = f"""
🔍 <b>Быстрый статус мониторинга</b>
📊 <b>Статус:</b> {running_status}
📈 <b>События за 24ч:</b> {status['stats_24h']['total_events']}
<b>Успешность:</b> {status['stats_24h']['success_rate']}%
Для подробного управления используйте админ-панель.
"""
await message.answer(text, parse_mode="HTML")
break
except Exception as e:
logger.error(f"Ошибка команды /monitoring: {e}")
await message.answer(f"❌ Ошибка: {str(e)}")
def register_handlers(dp):
"""Регистрация обработчиков мониторинга"""
dp.include_router(router)
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
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
from app.localization.texts import get_texts
from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
from app.database.crud.user import get_user_by_id
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_referral_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
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.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.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>
"""
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_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}%
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
<i>🕐 Обновлено: {current_time}</i>
"""
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_top_referrers, F.data == "admin_referrals_top")
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
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.states import AdminStates
from app.database.models import User
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
from app.database.crud.rules import get_current_rules_content, create_or_update_rules
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_rules_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = """
📋 <b>Управление правилами сервиса</b>
Текущие правила показываются пользователям при регистрации и в главном меню.
Выберите действие:
"""
keyboard = [
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
[types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def view_current_rules(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
current_rules = await get_current_rules_content(db, db_user.language)
await callback.message.edit_text(
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
])
)
await callback.answer()
@admin_required
@error_handler
async def start_edit_rules(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
current_rules = await get_current_rules_content(db, db_user.language)
await callback.message.edit_text(
"✏️ <b>Редактирование правил</b>\n\n"
f"<b>Текущие правила:</b>\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n"
"Отправьте новый текст правил сервиса.\n\n"
"<i>Поддерживается HTML разметка</i>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
])
)
await state.set_state(AdminStates.editing_rules_page)
await callback.answer()
@admin_required
@error_handler
async def process_rules_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
new_rules = message.text
if len(new_rules) > 4000:
await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)")
return
await message.answer(
f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
f"Сохранить изменения?",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
]
])
)
await state.update_data(new_rules=new_rules)
@admin_required
@error_handler
async def save_rules(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
data = await state.get_data()
new_rules = data.get('new_rules')
if not new_rules:
await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True)
return
try:
await create_or_update_rules(
db=db,
content=new_rules,
language=db_user.language
)
from app.localization.texts import clear_rules_cache
clear_rules_cache()
from app.localization.texts import refresh_rules_cache
await refresh_rules_cache(db_user.language)
await callback.message.edit_text(
"✅ <b>Правила сервиса обновлены!</b>\n\n"
"Новые правила сохранены в базе данных и будут показываться пользователям.\n\n"
"Кеш правил очищен и обновлен.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
])
)
await state.clear()
logger.info(f"Правила сервиса обновлены администратором {db_user.telegram_id}")
await callback.answer()
except Exception as e:
logger.error(f"Ошибка сохранения правил: {e}")
await callback.answer("❌ Ошибка сохранения правил", show_alert=True)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_rules_management, F.data == "admin_rules")
dp.callback_query.register(view_current_rules, F.data == "admin_view_rules")
dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules")
dp.callback_query.register(save_rules, F.data == "admin_save_rules")
dp.message.register(process_rules_edit, AdminStates.editing_rules_page)
+955
View File
@@ -0,0 +1,955 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.states import AdminStates
from app.database.models import User
from app.database.crud.server_squad import (
get_all_server_squads, get_server_squad_by_id, update_server_squad,
delete_server_squad, sync_with_remnawave, get_server_statistics,
create_server_squad, get_available_server_squads
)
from app.services.remnawave_service import RemnaWaveService
from app.utils.decorators import admin_required, error_handler
from app.utils.cache import cache
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_servers_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_server_statistics(db)
text = f"""
🌐 <b>Управление серверами</b>
📊 <b>Статистика:</b>
Всего серверов: {stats['total_servers']}
Доступные: {stats['available_servers']}
Недоступные: {stats['unavailable_servers']}
С подключениями: {stats['servers_with_connections']}
💰 <b>Выручка от серверов:</b>
Общая: {stats['total_revenue_rubles']:.2f}
Выберите действие:
"""
keyboard = [
[
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
types.InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync")
],
[
types.InlineKeyboardButton(text="📊 Синхронизировать счетчики", callback_data="admin_servers_sync_counts"),
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_servers_list(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
page: int = 1
):
servers, total_count = await get_all_server_squads(db, page=page, limit=10)
total_pages = (total_count + 9) // 10
if not servers:
text = "🌐 <b>Список серверов</b>\n\n❌ Серверы не найдены."
else:
text = f"🌐 <b>Список серверов</b>\n\n"
text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n"
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 "Бесплатно"
text += f"{i}. {status_emoji} {server.display_name}\n"
text += f" 💰 Цена: {price_text}"
if server.max_users:
text += f" | 👥 {server.current_users}/{server.max_users}"
text += f"\n UUID: <code>{server.squad_uuid}</code>\n\n"
keyboard = []
for i, server in enumerate(servers):
row_num = i // 2
if len(keyboard) <= row_num:
keyboard.append([])
status_emoji = "" if server.is_available else ""
keyboard[row_num].append(
types.InlineKeyboardButton(
text=f"{status_emoji} {server.display_name[:15]}...",
callback_data=f"admin_server_edit_{server.id}"
)
)
if total_pages > 1:
nav_row = []
if page > 1:
nav_row.append(types.InlineKeyboardButton(
text="⬅️", callback_data=f"admin_servers_list_page_{page-1}"
))
nav_row.append(types.InlineKeyboardButton(
text=f"{page}/{total_pages}", callback_data="current_page"
))
if page < total_pages:
nav_row.append(types.InlineKeyboardButton(
text="➡️", callback_data=f"admin_servers_list_page_{page+1}"
))
keyboard.append(nav_row)
keyboard.extend([
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def sync_servers_with_remnawave(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.message.edit_text(
"🔄 Синхронизация с RemnaWave...\n\nПодождите, это может занять время.",
reply_markup=None
)
try:
remnawave_service = RemnaWaveService()
squads = await remnawave_service.get_all_squads()
if not squads:
await callback.message.edit_text(
"❌ Не удалось получить данные о сквадах из RemnaWave.\n\nПроверьте настройки API.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
)
return
created, updated, disabled = await sync_with_remnawave(db, squads)
await cache.delete("available_countries")
text = f"""
<b>Синхронизация завершена</b>
📊 <b>Результаты:</b>
Создано новых серверов: {created}
Обновлено существующих: {updated}
Отключено неактивных: {disabled}
Всего обработано: {len(squads)}
Новые серверы созданы как недоступные.
Настройте их в списке серверов.
"""
keyboard = [
[
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync")
],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
except Exception as e:
logger.error(f"Ошибка синхронизации серверов: {e}")
await callback.message.edit_text(
f"❌ Ошибка синхронизации: {str(e)}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
)
await callback.answer()
@admin_required
@error_handler
async def show_server_edit_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
price_text = f"{server.price_rubles:.2f}" if server.price_kopeks > 0 else "Бесплатно"
text = f"""
🌐 <b>Редактирование сервера</b>
<b>Информация:</b>
ID: {server.id}
UUID: <code>{server.squad_uuid}</code>
Название: {server.display_name}
Оригинальное: {server.original_name or 'Не указано'}
Статус: {status_emoji}
<b>Настройки:</b>
Цена: {price_text}
Код страны: {server.country_code or 'Не указан'}
Лимит пользователей: {server.max_users or 'Без лимита'}
Текущих пользователей: {server.current_users}
<b>Описание:</b>
{server.description or 'Не указано'}
Выберите что изменить:
"""
keyboard = [
[
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
],
[
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
],
[
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
],
[
types.InlineKeyboardButton(
text="❌ Отключить" if server.is_available else "✅ Включить",
callback_data=f"admin_server_toggle_{server.id}"
)
],
[
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def toggle_server_availability(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
new_status = not server.is_available
await update_server_squad(db, server_id, is_available=new_status)
await cache.delete("available_countries")
status_text = "включен" if new_status else "отключен"
await callback.answer(f"✅ Сервер {status_text}!")
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 "Бесплатно"
text = f"""
🌐 <b>Редактирование сервера</b>
<b>Информация:</b>
ID: {server.id}
UUID: <code>{server.squad_uuid}</code>
Название: {server.display_name}
Оригинальное: {server.original_name or 'Не указано'}
Статус: {status_emoji}
<b>Настройки:</b>
Цена: {price_text}
Код страны: {server.country_code or 'Не указан'}
Лимит пользователей: {server.max_users or 'Без лимита'}
Текущих пользователей: {server.current_users}
<b>Описание:</b>
{server.description or 'Не указано'}
Выберите что изменить:
"""
keyboard = [
[
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
],
[
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
],
[
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
],
[
types.InlineKeyboardButton(
text="❌ Отключить" if server.is_available else "✅ Включить",
callback_data=f"admin_server_toggle_{server.id}"
)
],
[
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
@admin_required
@error_handler
async def start_server_edit_price(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
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 "Бесплатно"
await callback.message.edit_text(
f"💰 <b>Редактирование цены</b>\n\n"
f"Текущая цена: <b>{current_price}</b>\n\n"
f"Отправьте новую цену в рублях (например: 15.50) или 0 для бесплатного доступа:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def process_server_price_edit(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
data = await state.get_data()
server_id = data.get('server_id')
try:
price_rubles = float(message.text.replace(',', '.'))
if price_rubles < 0:
await message.answer("❌ Цена не может быть отрицательной")
return
if price_rubles > 10000:
await message.answer("❌ Слишком высокая цена (максимум 10,000 ₽)")
return
price_kopeks = int(price_rubles * 100)
server = await update_server_squad(db, server_id, price_kopeks=price_kopeks)
if server:
await state.clear()
await cache.delete("available_countries")
price_text = f"{price_rubles:.2f}" if price_kopeks > 0 else "Бесплатно"
await message.answer(
f"✅ Цена сервера изменена на: <b>{price_text}</b>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
else:
await message.answer("❌ Ошибка при обновлении сервера")
except ValueError:
await message.answer("❌ Неверный формат цены. Используйте числа (например: 15.50)")
@admin_required
@error_handler
async def start_server_edit_name(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
await state.set_data({'server_id': server_id})
await state.set_state(AdminStates.editing_server_name)
await callback.message.edit_text(
f"✏️ <b>Редактирование названия</b>\n\n"
f"Текущее название: <b>{server.display_name}</b>\n\n"
f"Отправьте новое название для сервера:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def process_server_name_edit(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
data = await state.get_data()
server_id = data.get('server_id')
new_name = message.text.strip()
if len(new_name) > 255:
await message.answer("❌ Название слишком длинное (максимум 255 символов)")
return
if len(new_name) < 3:
await message.answer("❌ Название слишком короткое (минимум 3 символа)")
return
server = await update_server_squad(db, server_id, display_name=new_name)
if server:
await state.clear()
await cache.delete("available_countries")
await message.answer(
f"✅ Название сервера изменено на: <b>{new_name}</b>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
else:
await message.answer("❌ Ошибка при обновлении сервера")
@admin_required
@error_handler
async def delete_server_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
text = f"""
🗑 <b>Удаление сервера</b>
Вы действительно хотите удалить сервер:
<b>{server.display_name}</b>
<b>Внимание!</b>
Сервер можно удалить только если к нему нет активных подключений.
Это действие нельзя отменить!
"""
keyboard = [
[
types.InlineKeyboardButton(text="🗑️ Да, удалить", callback_data=f"admin_server_delete_confirm_{server_id}"),
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def delete_server_execute(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
success = await delete_server_squad(db, server_id)
if success:
await cache.delete("available_countries")
await callback.message.edit_text(
f"✅ Сервер <b>{server.display_name}</b> успешно удален!",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📋 К списку серверов", callback_data="admin_servers_list")]
]),
parse_mode="HTML"
)
else:
await callback.message.edit_text(
f"❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\n"
f"Возможно, к нему есть активные подключения.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_server_detailed_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_server_statistics(db)
available_servers = await get_available_server_squads(db)
text = f"""
📊 <b>Подробная статистика серверов</b>
<b>🌐 Общая информация:</b>
Всего серверов: {stats['total_servers']}
Доступные: {stats['available_servers']}
Недоступные: {stats['unavailable_servers']}
С активными подключениями: {stats['servers_with_connections']}
<b>💰 Финансовая статистика:</b>
Общая выручка: {stats['total_revenue_rubles']:.2f}
Средняя цена за сервер: {(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1)):.2f}
<b>🔥 Топ серверов по цене:</b>
"""
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 "Бесплатно"
text += f"{i}. {server.display_name} - {price_text}\n"
if not sorted_servers:
text += "Нет доступных серверов\n"
keyboard = [
[
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_stats"),
types.InlineKeyboardButton(text="📋 Список", callback_data="admin_servers_list")
],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def start_server_edit_country(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
await state.set_data({'server_id': server_id})
await state.set_state(AdminStates.editing_server_country)
current_country = server.country_code or "Не указан"
await callback.message.edit_text(
f"🌍 <b>Редактирование кода страны</b>\n\n"
f"Текущий код страны: <b>{current_country}</b>\n\n"
f"Отправьте новый код страны (например: RU, US, DE) или '-' для удаления:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def process_server_country_edit(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
data = await state.get_data()
server_id = data.get('server_id')
new_country = message.text.strip().upper()
if new_country == "-":
new_country = None
elif len(new_country) > 5:
await message.answer("❌ Код страны слишком длинный (максимум 5 символов)")
return
server = await update_server_squad(db, server_id, country_code=new_country)
if server:
await state.clear()
await cache.delete("available_countries")
country_text = new_country or "Удален"
await message.answer(
f"✅ Код страны изменен на: <b>{country_text}</b>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
else:
await message.answer("❌ Ошибка при обновлении сервера")
@admin_required
@error_handler
async def start_server_edit_limit(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
await state.set_data({'server_id': server_id})
await state.set_state(AdminStates.editing_server_limit)
current_limit = server.max_users or "Без лимита"
await callback.message.edit_text(
f"👥 <b>Редактирование лимита пользователей</b>\n\n"
f"Текущий лимит: <b>{current_limit}</b>\n\n"
f"Отправьте новый лимит пользователей (число) или 0 для безлимитного доступа:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def process_server_limit_edit(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
data = await state.get_data()
server_id = data.get('server_id')
try:
limit = int(message.text.strip())
if limit < 0:
await message.answer("❌ Лимит не может быть отрицательным")
return
if limit > 10000:
await message.answer("❌ Слишком большой лимит (максимум 10,000)")
return
max_users = limit if limit > 0 else None
server = await update_server_squad(db, server_id, max_users=max_users)
if server:
await state.clear()
limit_text = f"{limit} пользователей" if limit > 0 else "Без лимита"
await message.answer(
f"✅ Лимит пользователей изменен на: <b>{limit_text}</b>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
else:
await message.answer("❌ Ошибка при обновлении сервера")
except ValueError:
await message.answer("❌ Неверный формат числа. Введите целое число.")
@admin_required
@error_handler
async def start_server_edit_description(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
await state.set_data({'server_id': server_id})
await state.set_state(AdminStates.editing_server_description)
current_desc = server.description or "Не указано"
await callback.message.edit_text(
f"📝 <b>Редактирование описания</b>\n\n"
f"Текущее описание:\n<i>{current_desc}</i>\n\n"
f"Отправьте новое описание сервера или '-' для удаления:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def process_server_description_edit(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
data = await state.get_data()
server_id = data.get('server_id')
new_description = message.text.strip()
if new_description == "-":
new_description = None
elif len(new_description) > 1000:
await message.answer("❌ Описание слишком длинное (максимум 1000 символов)")
return
server = await update_server_squad(db, server_id, description=new_description)
if server:
await state.clear()
desc_text = new_description or "Удалено"
await message.answer(
f"✅ Описание сервера изменено:\n\n<i>{desc_text}</i>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
]),
parse_mode="HTML"
)
else:
await message.answer("❌ Ошибка при обновлении сервера")
@admin_required
@error_handler
async def sync_server_user_counts_handler(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.message.edit_text(
"🔄 Синхронизация счетчиков пользователей...",
reply_markup=None
)
try:
from app.database.crud.server_squad import sync_server_user_counts
updated_count = await sync_server_user_counts(db)
text = f"""
<b>Синхронизация завершена</b>
📊 <b>Результат:</b>
Обновлено серверов: {updated_count}
Счетчики пользователей синхронизированы с реальными данными.
"""
keyboard = [
[
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync_counts")
],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
except Exception as e:
logger.error(f"Ошибка синхронизации счетчиков: {e}")
await callback.message.edit_text(
f"❌ Ошибка синхронизации: {str(e)}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
)
await callback.answer()
@admin_required
@error_handler
async def handle_servers_pagination(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = int(callback.data.split('_')[-1])
await show_servers_list(callback, db_user, db, page)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_servers_menu, F.data == "admin_servers")
dp.callback_query.register(show_servers_list, F.data == "admin_servers_list")
dp.callback_query.register(sync_servers_with_remnawave, F.data == "admin_servers_sync")
dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts")
dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats")
dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc"))
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
dp.message.register(process_server_name_edit, AdminStates.editing_server_name)
dp.message.register(process_server_price_edit, AdminStates.editing_server_price)
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
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_"))
+396
View File
@@ -0,0 +1,396 @@
import logging
from datetime import datetime, timedelta
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.admin import get_admin_statistics_keyboard, get_period_selection_keyboard
from app.localization.texts import get_texts
from app.services.user_service import UserService
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period
from app.database.crud.referral import get_referral_statistics
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_percentage
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_statistics_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = """
📊 <b>Статистика системы</b>
Выберите раздел для просмотра статистики:
"""
await callback.message.edit_text(
text,
reply_markup=get_admin_statistics_keyboard(db_user.language)
)
await callback.answer()
@admin_required
@error_handler
async def show_users_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_service = UserService()
stats = await user_service.get_user_statistics(db)
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>
<b>Общие показатели:</b>
- Всего зарегистрировано: {stats['total_users']}
- Активных: {stats['active_users']} ({active_rate})
- Заблокированных: {stats['blocked_users']}
<b>Новые регистрации:</b>
- Сегодня: {stats['new_today']}
- За неделю: {stats['new_week']}
- За месяц: {stats['new_month']}
<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}
"""
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
@error_handler
async def show_subscriptions_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_subscriptions_statistics(db)
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>
<b>Общие показатели:</b>
- Всего подписок: {stats['total_subscriptions']}
- Активных: {stats['active_subscriptions']}
- Платных: {stats['paid_subscriptions']}
- Триальных: {stats['trial_subscriptions']}
<b>Конверсия:</b>
- Из триала в платную: {conversion_rate}
- Активных платных: {stats['paid_subscriptions']}
<b>Продажи:</b>
- Сегодня: {stats['purchased_today']}
- За неделю: {stats['purchased_week']}
- За месяц: {stats['purchased_month']}
<b>Обновлено:</b> {current_time}
"""
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
@error_handler
async def show_revenue_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
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>
<b>За текущий месяц:</b>
- Доходы: {settings.format_price(month_stats['totals']['income_kopeks'])}
- Расходы: {settings.format_price(month_stats['totals']['expenses_kopeks'])}
- Прибыль: {settings.format_price(month_stats['totals']['profit_kopeks'])}
- От подписок: {settings.format_price(month_stats['totals']['subscription_income_kopeks'])}
<b>Сегодня:</b>
- Транзакций: {month_stats['today']['transactions_count']}
- Доходы: {settings.format_price(month_stats['today']['income_kopeks'])}
<b>За все время:</b>
- Общий доход: {settings.format_price(all_time_stats['totals']['income_kopeks'])}
- Общая прибыль: {settings.format_price(all_time_stats['totals']['profit_kopeks'])}
<b>Способы оплаты:</b>
"""
for method, data in month_stats['by_payment_method'].items():
if method and data['count'] > 0:
text += f"{method}: {data['count']} ({settings.format_price(data['amount'])})\n"
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
@error_handler
async def show_referral_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_referral_statistics(db)
current_time = format_datetime(datetime.utcnow())
avg_per_referrer = 0
if stats['active_referrers'] > 0:
avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers']
text = f"""
🤝 <b>Реферальная статистика</b>
<b>Общие показатели:</b>
- Пользователей с рефералами: {stats['users_with_referrals']}
- Активных рефереров: {stats['active_referrers']}
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
<b>За период:</b>
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
<b>Средние показатели:</b>
- На одного рефререра: {settings.format_price(int(avg_per_referrer))}
<b>Топ рефереры:</b>
"""
if stats['top_referrers']:
for i, referrer in enumerate(stats['top_referrers'][:5], 1):
name = referrer['display_name']
earned = settings.format_price(referrer['total_earned_kopeks'])
count = referrer['referrals_count']
text += f"{i}. {name}: {earned} ({count} реф.)\n"
else:
text += "Пока нет активных рефереров"
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
@error_handler
async def show_summary_statistics(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
user_service = UserService()
user_stats = await user_service.get_user_statistics(db)
sub_stats = await get_subscriptions_statistics(db)
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:
conversion_rate = sub_stats['paid_subscriptions'] / user_stats['total_users'] * 100
arpu = 0
if user_stats['active_users'] > 0:
arpu = revenue_stats['totals']['income_kopeks'] / user_stats['active_users']
text = f"""
📊 <b>Общая сводка системы</b>
<b>Пользователи:</b>
- Всего: {user_stats['total_users']}
- Активных: {user_stats['active_users']}
- Новых за месяц: {user_stats['new_month']}
<b>Подписки:</b>
- Активных: {sub_stats['active_subscriptions']}
- Платных: {sub_stats['paid_subscriptions']}
- Конверсия: {format_percentage(conversion_rate)}
<b>Финансы (месяц):</b>
- Доходы: {settings.format_price(revenue_stats['totals']['income_kopeks'])}
- ARPU: {settings.format_price(int(arpu))}
- Транзакций: {sum(data['count'] for data in revenue_stats['by_type'].values())}
<b>Рост:</b>
- Пользователи: +{user_stats['new_month']} за месяц
- Продажи: +{sub_stats['purchased_month']} за месяц
<b>Обновлено:</b> {current_time}
"""
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
async def show_revenue_by_period(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
period = callback.data.split('_')[-1]
period_map = {
"today": 1,
"yesterday": 1,
"week": 7,
"month": 30,
"all": 365
}
days = period_map.get(period, 30)
revenue_data = await get_revenue_by_period(db, days)
if period == "yesterday":
yesterday = datetime.utcnow().date() - timedelta(days=1)
revenue_data = [r for r in revenue_data if r['date'] == yesterday]
elif period == "today":
today = datetime.utcnow().date()
revenue_data = [r for r in revenue_data if r['date'] == today]
total_revenue = sum(r['amount_kopeks'] for r in revenue_data)
avg_daily = total_revenue / len(revenue_data) if revenue_data else 0
text = f"""
📈 <b>Доходы за период: {period}</b>
<b>Сводка:</b>
- Общий доход: {settings.format_price(total_revenue)}
- Дней с данными: {len(revenue_data)}
- Средний доход в день: {settings.format_price(int(avg_daily))}
<b>По дням:</b>
"""
for revenue in revenue_data[-10:]:
text += f"{revenue['date'].strftime('%d.%m')}: {settings.format_price(revenue['amount_kopeks'])}\n"
if len(revenue_data) > 10:
text += f"... и еще {len(revenue_data) - 10} дней"
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")]
])
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_statistics_menu, F.data == "admin_statistics")
dp.callback_query.register(show_users_statistics, F.data == "admin_stats_users")
dp.callback_query.register(show_subscriptions_statistics, F.data == "admin_stats_subs")
dp.callback_query.register(show_revenue_statistics, F.data == "admin_stats_revenue")
dp.callback_query.register(show_referral_statistics, F.data == "admin_stats_referrals")
dp.callback_query.register(show_summary_statistics, F.data == "admin_stats_summary")
dp.callback_query.register(show_revenue_by_period, F.data.startswith("period_"))
periods = ["today", "yesterday", "week", "month", "all"]
for period in periods:
dp.callback_query.register(
show_revenue_by_period,
F.data == f"period_{period}"
)
+497
View File
@@ -0,0 +1,497 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.config import settings
from app.states import AdminStates
from app.database.models import User
from app.keyboards.admin import get_admin_subscriptions_keyboard
from app.localization.texts import get_texts
from app.database.crud.subscription import (
get_expiring_subscriptions, get_subscriptions_statistics, get_expired_subscriptions,
get_all_subscriptions
)
from app.services.subscription_service import SubscriptionService
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago
def get_country_flag(country_name: str) -> str:
flags = {
'USA': '🇺🇸', 'United States': '🇺🇸', 'US': '🇺🇸',
'Germany': '🇩🇪', 'DE': '🇩🇪', 'Deutschland': '🇩🇪',
'Netherlands': '🇳🇱', 'NL': '🇳🇱', 'Holland': '🇳🇱',
'United Kingdom': '🇬🇧', 'UK': '🇬🇧', 'GB': '🇬🇧',
'Japan': '🇯🇵', 'JP': '🇯🇵',
'France': '🇫🇷', 'FR': '🇫🇷',
'Canada': '🇨🇦', 'CA': '🇨🇦',
'Russia': '🇷🇺', 'RU': '🇷🇺',
'Singapore': '🇸🇬', 'SG': '🇸🇬',
}
return flags.get(country_name, '🌍')
async def get_users_by_countries(db: AsyncSession) -> dict:
try:
result = await db.execute(
select(User.preferred_location, func.count(User.id))
.where(User.preferred_location.isnot(None))
.group_by(User.preferred_location)
)
stats = {}
for location, count in result.fetchall():
if location:
stats[location] = count
return stats
except Exception as e:
logger.error(f"Ошибка получения статистики по странам: {e}")
return {}
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_subscriptions_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_subscriptions_statistics(db)
text = f"""
📱 <b>Управление подписками</b>
📊 <b>Статистика:</b>
- Всего: {stats['total_subscriptions']}
- Активных: {stats['active_subscriptions']}
- Платных: {stats['paid_subscriptions']}
- Триальных: {stats['trial_subscriptions']}
📈 <b>Продажи:</b>
- Сегодня: {stats['purchased_today']}
- За неделю: {stats['purchased_week']}
- За месяц: {stats['purchased_month']}
Выберите действие:
"""
keyboard = [
[
types.InlineKeyboardButton(text="📋 Список подписок", callback_data="admin_subs_list"),
types.InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring")
],
[
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
],
[
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_subscriptions_list(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
page: int = 1
):
subscriptions, total_count = await get_all_subscriptions(db, page=page, limit=10)
total_pages = (total_count + 9) // 10
if not subscriptions:
text = "📱 <b>Список подписок</b>\n\n❌ Подписки не найдены."
else:
text = f"📱 <b>Список подписок</b>\n\n"
text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n"
for i, sub in enumerate(subscriptions, 1 + (page - 1) * 10):
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
sub_type = "🎁" if sub.is_trial else "💎"
status = "✅ Активна" if sub.is_active else "❌ Неактивна"
text += f"{i}. {sub_type} {user_info}\n"
text += f" {status} | До: {format_datetime(sub.end_date)}\n"
if sub.device_limit > 0:
text += f" 📱 Устройств: {sub.device_limit}\n"
text += "\n"
keyboard = []
if total_pages > 1:
nav_row = []
if page > 1:
nav_row.append(types.InlineKeyboardButton(
text="⬅️", callback_data=f"admin_subs_list_page_{page-1}"
))
nav_row.append(types.InlineKeyboardButton(
text=f"{page}/{total_pages}", callback_data="current_page"
))
if page < total_pages:
nav_row.append(types.InlineKeyboardButton(
text="➡️", callback_data=f"admin_subs_list_page_{page+1}"
))
keyboard.append(nav_row)
keyboard.extend([
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_list")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_expiring_subscriptions(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
expiring_3d = await get_expiring_subscriptions(db, 3)
expiring_1d = await get_expiring_subscriptions(db, 1)
expired = await get_expired_subscriptions(db)
text = f"""
<b>Истекающие подписки</b>
📊 <b>Статистика:</b>
- Истекают через 3 дня: {len(expiring_3d)}
- Истекают завтра: {len(expiring_1d)}
- Уже истекли: {len(expired)}
<b>Истекают через 3 дня:</b>
"""
for sub in expiring_3d[:5]:
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
sub_type = "🎁" if sub.is_trial else "💎"
text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n"
if len(expiring_3d) > 5:
text += f"... и еще {len(expiring_3d) - 5}\n"
text += f"\n<b>Истекают завтра:</b>\n"
for sub in expiring_1d[:5]:
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
sub_type = "🎁" if sub.is_trial else "💎"
text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n"
if len(expiring_1d) > 5:
text += f"... и еще {len(expiring_1d) - 5}\n"
keyboard = [
[types.InlineKeyboardButton(text="📨 Отправить напоминания", callback_data="admin_send_expiry_reminders")],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_expiring")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_subscriptions_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_subscriptions_statistics(db)
expiring_3d = await get_expiring_subscriptions(db, 3)
expiring_7d = await get_expiring_subscriptions(db, 7)
expired = await get_expired_subscriptions(db)
text = f"""
📊 <b>Детальная статистика подписок</b>
<b>📱 Общая информация:</b>
Всего подписок: {stats['total_subscriptions']}
Активных: {stats['active_subscriptions']}
Неактивных: {stats['total_subscriptions'] - stats['active_subscriptions']}
<b>💎 По типам:</b>
Платных: {stats['paid_subscriptions']}
Триальных: {stats['trial_subscriptions']}
<b>📈 Продажи:</b>
Сегодня: {stats['purchased_today']}
За неделю: {stats['purchased_week']}
За месяц: {stats['purchased_month']}
<b> Истечение:</b>
Истекают через 3 дня: {len(expiring_3d)}
Истекают через 7 дней: {len(expiring_7d)}
Уже истекли: {len(expired)}
<b>💰 Конверсия:</b>
Из триала в платную: {stats.get('trial_to_paid_conversion', 0)}%
Продлений: {stats.get('renewals_count', 0)}
"""
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_subscriptions")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_pricing_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = f"""
<b>Настройки цен</b>
<b>Периоды подписки:</b>
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
<b>Трафик-пакеты:</b>
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
<b>Дополнительно:</b>
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
"""
keyboard = [
# [
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
# ],
# [
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
# ],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_countries_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
from app.services.remnawave_service import RemnaWaveService
remnawave_service = RemnaWaveService()
nodes_data = await remnawave_service.get_all_nodes()
squads_data = await remnawave_service.get_all_squads()
text = "🌍 <b>Управление странами</b>\n\n"
if nodes_data:
text += "<b>Доступные серверы:</b>\n"
countries = {}
for node in nodes_data:
country_code = node.get('country_code', 'XX')
country_name = country_code
if country_name not in countries:
countries[country_name] = []
countries[country_name].append(node)
for country, nodes in countries.items():
active_nodes = len([n for n in nodes if n.get('is_connected') and n.get('is_node_online')])
total_nodes = len(nodes)
country_flag = get_country_flag(country)
text += f"{country_flag} {country}: {active_nodes}/{total_nodes} серверов\n"
total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes)
if total_users_online > 0:
text += f" 👥 Пользователей онлайн: {total_users_online}\n"
else:
text += "❌ Не удалось загрузить данные о серверах\n"
if squads_data:
text += f"\n<b>Всего сквадов:</b> {len(squads_data)}\n"
total_members = sum(squad.get('members_count', 0) for squad in squads_data)
text += f"<b>Участников в сквадах:</b> {total_members}\n"
text += "\n<b>Сквады:</b>\n"
for squad in squads_data[:5]:
name = squad.get('name', 'Неизвестно')
members = squad.get('members_count', 0)
inbounds = squad.get('inbounds_count', 0)
text += f"{name}: {members} участников, {inbounds} inbound(s)\n"
if len(squads_data) > 5:
text += f"... и еще {len(squads_data) - 5} сквадов\n"
user_stats = await get_users_by_countries(db)
if user_stats:
text += "\n<b>Пользователи по регионам:</b>\n"
for country, count in user_stats.items():
country_flag = get_country_flag(country)
text += f"{country_flag} {country}: {count} пользователей\n"
except Exception as e:
logger.error(f"Ошибка получения данных о странах: {e}")
text = f"""
🌍 <b>Управление странами</b>
<b>Ошибка загрузки данных</b>
Не удалось получить информацию о серверах.
Проверьте подключение к RemnaWave API.
<b>Детали ошибки:</b> {str(e)}
"""
keyboard = [
[
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries")
],
[
types.InlineKeyboardButton(text="📊 Статистика нод", callback_data="admin_rw_nodes"),
types.InlineKeyboardButton(text="🔧 Сквады", callback_data="admin_rw_squads")
],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def send_expiry_reminders(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.message.edit_text(
"📨 Отправка напоминаний...\n\nПодождите, это может занять время.",
reply_markup=None
)
expiring_subs = await get_expiring_subscriptions(db, 1)
sent_count = 0
for subscription in expiring_subs:
if subscription.user:
try:
user = subscription.user
days_left = max(1, subscription.days_left)
reminder_text = f"""
<b>Подписка истекает!</b>
Ваша подписка истекает через {days_left} день(а).
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
💎 Продлить подписку можно в главном меню.
"""
await callback.bot.send_message(
chat_id=user.telegram_id,
text=reminder_text
)
sent_count += 1
except Exception as e:
logger.error(f"Ошибка отправки напоминания пользователю {subscription.user_id}: {e}")
await callback.message.edit_text(
f"✅ Напоминания отправлены: {sent_count} из {len(expiring_subs)}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subs_expiring")]
])
)
await callback.answer()
@admin_required
@error_handler
async def handle_subscriptions_pagination(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = int(callback.data.split('_')[-1])
await show_subscriptions_list(callback, db_user, db, page)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_subscriptions_menu, F.data == "admin_subscriptions")
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
dp.callback_query.register(
handle_subscriptions_pagination,
F.data.startswith("admin_subs_list_page_")
)
File diff suppressed because it is too large Load Diff
+618
View File
@@ -0,0 +1,618 @@
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.states import BalanceStates
from app.database.crud.user import add_user_balance
from app.database.crud.transaction import (
get_user_transactions, get_user_transactions_count,
create_transaction
)
from app.database.models import User, TransactionType, PaymentMethod
from app.keyboards.inline import (
get_balance_keyboard, get_payment_methods_keyboard,
get_back_keyboard, get_pagination_keyboard
)
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.pagination import paginate_list
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
@error_handler
async def show_balance_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
balance_text = texts.BALANCE_INFO.format(
balance=texts.format_price(db_user.balance_kopeks)
)
await callback.message.edit_text(
balance_text,
reply_markup=get_balance_keyboard(db_user.language)
)
await callback.answer()
@error_handler
async def show_balance_history(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
page: int = 1
):
texts = get_texts(db_user.language)
offset = (page - 1) * TRANSACTIONS_PER_PAGE
raw_transactions = await get_user_transactions(
db, db_user.id,
limit=TRANSACTIONS_PER_PAGE * 3,
offset=offset
)
seen_transactions = set()
unique_transactions = []
for transaction in raw_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_transactions:
seen_transactions.add(transaction_key)
unique_transactions.append(transaction)
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
break
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
seen_all = set()
total_unique = 0
for transaction in all_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_all:
seen_all.add(transaction_key)
total_unique += 1
if not unique_transactions:
await callback.message.edit_text(
"📊 История операций пуста",
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
text = "📊 <b>История операций</b>\n\n"
for transaction in unique_transactions:
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
text += f"{emoji} {amount_text}\n"
text += f"📝 {transaction.description}\n"
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
keyboard = []
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
if total_pages > 1:
pagination_row = get_pagination_keyboard(
page, total_pages, "balance_history", db_user.language
)
keyboard.extend(pagination_row)
keyboard.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_balance_history_pagination(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = int(callback.data.split('_')[-1])
await show_balance_history(callback, db_user, db, page)
@error_handler
async def show_payment_methods(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
payment_text = """
💳 <b>Способы пополнения баланса</b>
Выберите удобный для вас способ оплаты:
<b>Telegram Stars</b> - быстро и удобно
💳 <b>Банковская карта</b> - через YooKassa/Tribute
🛠 <b>Через поддержку</b> - другие способы
Выберите способ пополнения:
"""
await callback.message.edit_text(
payment_text,
reply_markup=get_payment_methods_keyboard(0, db_user.language),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def start_stars_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
return
await callback.message.edit_text(
texts.TOP_UP_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="stars")
await callback.answer()
@error_handler
async def start_yookassa_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
return
await callback.message.edit_text(
"💳 <b>Оплата банковской картой</b>\n\n"
"Введите сумму для пополнения от 100 до 50,000 рублей:",
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="yookassa")
await callback.answer()
@error_handler
async def start_tribute_payment(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
if not settings.TRIBUTE_ENABLED:
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
return
try:
from app.services.tribute_service import TributeService
tribute_service = TributeService(callback.bot)
payment_url = await tribute_service.create_payment_link(
user_id=db_user.telegram_id,
amount_kopeks=0,
description="Пополнение баланса VPN"
)
if not payment_url:
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
f"💳 <b>Пополнение банковской картой</b>\n\n"
f"• Введите любую сумму от 100₽\n"
f"• Безопасная оплата через Tribute\n"
f"• Мгновенное зачисление на баланс\n"
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
f"Нажмите кнопку для перехода к оплате:",
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка создания Tribute платежа: {e}")
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
@error_handler
async def request_support_topup(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
support_text = f"""
🛠 <b>Пополнение через поддержку</b>
Для пополнения баланса обратитесь в техподдержку:
{settings.SUPPORT_USERNAME}
Укажите:
ID: {db_user.telegram_id}
Сумму пополнения
Способ оплаты
Время обработки: 1-24 часа
<b>Доступные способы:</b>
Криптовалюта
Переводы между банками
Другие платежные системы
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="💬 Написать в поддержку",
url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}"
)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
support_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def process_topup_amount(
message: types.Message,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
try:
amount_rubles = float(message.text.replace(',', '.'))
if amount_rubles < 1:
await message.answer("Минимальная сумма пополнения: 1 ₽")
return
if amount_rubles > 50000:
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
return
amount_kopeks = int(amount_rubles * 100)
data = await state.get_data()
payment_method = data.get("payment_method", "stars")
if payment_method == "stars":
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
elif payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
else:
await message.answer("Неизвестный способ оплаты")
except ValueError:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
@error_handler
async def process_stars_payment_amount(
message: types.Message,
db_user: User,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
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,
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"К оплате: {stars_amount} звезд\n"
f"📊 Курс: {settings.get_stars_rate():.2f}₽ за звезду\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await message.answer("⚠ Ошибка создания платежа")
@error_handler
async def process_yookassa_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await message.answer("❌ Оплата через YooKassa временно недоступна")
return
if amount_kopeks < 10000:
await message.answer("❌ Минимальная сумма для оплаты картой: 100 ₽")
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
receipt_email=None,
receipt_phone=None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"purpose": "balance_topup"
}
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
confirmation_url = payment_result.get("confirmation_url")
if not confirmation_url:
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Оплатить картой", url=confirmation_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"💳 <b>Оплата банковской картой</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить картой'\n"
f"2. Введите данные вашей карты\n"
f"3. Подтвердите платеж\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
f"{amount_kopeks/100}₽, ID: {payment_result['yookassa_payment_id']}")
except Exception as e:
logger.error(f"Ошибка создания YooKassa платежа: {e}")
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def check_yookassa_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
status_emoji = {
"pending": "",
"waiting_for_capture": "",
"succeeded": "",
"canceled": "",
"failed": ""
}
status_text = {
"pending": "Ожидает оплаты",
"waiting_for_capture": "Ожидает подтверждения",
"succeeded": "Оплачен",
"canceled": "Отменен",
"failed": "Ошибка"
}
emoji = status_emoji.get(payment.status, "")
status = status_text.get(payment.status, "Неизвестно")
message_text = (f"💳 Статус платежа:\n\n"
f"🆔 ID: {payment.yookassa_payment_id[:8]}...\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"📊 Статус: {emoji} {status}\n"
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
if payment.is_succeeded:
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_failed:
message_text += f"\n❌ Платеж не прошел. Обратитесь в {settings.SUPPORT_USERNAME}"
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
@error_handler
async def add_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str = "Пополнение баланса"
) -> 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
)
await db.commit()
await db.refresh(user)
if "пополнение" in description.lower() or "topup" in description.lower():
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, amount_kopeks)
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})")
return True
except Exception as e:
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
await db.rollback()
return False
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_balance_menu,
F.data == "menu_balance"
)
dp.callback_query.register(
show_balance_history,
F.data == "balance_history"
)
dp.callback_query.register(
handle_balance_history_pagination,
F.data.startswith("balance_history_page_")
)
dp.callback_query.register(
show_payment_methods,
F.data == "balance_topup"
)
dp.callback_query.register(
start_stars_payment,
F.data == "topup_stars"
)
dp.callback_query.register(
start_yookassa_payment,
F.data == "topup_yookassa"
)
dp.callback_query.register(
start_tribute_payment,
F.data == "topup_tribute"
)
dp.callback_query.register(
request_support_topup,
F.data == "topup_support"
)
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
dp.message.register(
process_topup_amount,
BalanceStates.waiting_for_amount
)
+85
View File
@@ -0,0 +1,85 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.keyboards.inline import get_back_keyboard
logger = logging.getLogger(__name__)
async def handle_unknown_callback(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language if db_user else "ru")
await callback.answer(
"❓ Неизвестная команда. Попробуйте ещё раз.",
show_alert=True
)
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
async def handle_cancel(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
texts = get_texts(db_user.language)
await state.clear()
await callback.message.edit_text(
texts.OPERATION_CANCELLED,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
async def handle_unknown_message(
message: types.Message,
db_user: User
):
texts = get_texts(db_user.language if db_user else "ru")
await message.answer(
"❓ Не понимаю эту команду. Используйте кнопки меню.",
reply_markup=get_back_keyboard(db_user.language if db_user else "ru")
)
async def show_rules(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
rules_text = texts.RULES_TEXT
await callback.message.edit_text(
rules_text,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_rules,
F.data == "menu_rules"
)
dp.callback_query.register(
handle_cancel,
F.data.in_(["cancel", "subscription_cancel"])
)
+150
View File
@@ -0,0 +1,150 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
from app.keyboards.inline import get_main_menu_keyboard
from app.localization.texts import get_texts
from app.database.models import User
from app.utils.user_utils import mark_user_as_had_paid_subscription
logger = logging.getLogger(__name__)
async def show_main_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
from datetime import datetime
db_user.last_activity = datetime.utcnow()
await db.commit()
has_active_subscription = bool(db_user.subscription)
subscription_is_active = False
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = texts.MAIN_MENU.format(
user_name=db_user.full_name,
subscription_status=_get_subscription_status(db_user, texts)
)
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
)
)
await callback.answer()
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 show_service_rules(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.database.crud.rules import get_current_rules_content
rules_text = await get_current_rules_content(db, db_user.language)
if not rules_text:
texts = get_texts(db_user.language)
rules_text = texts._get_default_rules(db_user.language) if hasattr(texts, '_get_default_rules') else """
📋 <b>Правила использования сервиса</b>
1. Запрещается использование сервиса для незаконной деятельности
2. Запрещается нарушение авторских прав
3. Запрещается спам и рассылка вредоносного ПО
4. Запрещается использование сервиса для DDoS атак
5. Один аккаунт - один пользователь
6. Возврат средств производится только в исключительных случаях
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
"""
await callback.message.edit_text(
f"📋 <b>Правила сервиса</b>\n\n{rules_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
])
)
await callback.answer()
async def handle_back_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
await state.clear()
await show_main_menu(callback, db_user, db)
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
return "❌ Отсутствует"
subscription = user.subscription
current_time = datetime.utcnow()
if subscription.end_date <= current_time:
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
days_left = (subscription.end_date - current_time).days
if subscription.is_trial:
if days_left > 1:
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left == 1:
return f"🎁 Тестовая подписка\n⚠️ истекает завтра!"
else:
return f"🎁 Тестовая подписка\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 f"💎 Активна\n⚠️ истекает завтра!"
else:
return f"💎 Активна\n⚠️ истекает сегодня!"
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
handle_back_to_menu,
F.data == "back_to_menu"
)
dp.callback_query.register(
show_service_rules,
F.data == "menu_rules"
)
+89
View File
@@ -0,0 +1,89 @@
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.states import PromoCodeStates
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.promocode_service import PromoCodeService
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def show_promocode_menu(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.PROMOCODE_ENTER,
reply_markup=get_back_keyboard(db_user.language)
)
await state.set_state(PromoCodeStates.waiting_for_code)
await callback.answer()
@error_handler
async def process_promocode(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
texts = get_texts(db_user.language)
code = message.text.strip()
if not code:
await message.answer(
"❌ Введите корректный промокод",
reply_markup=get_back_keyboard(db_user.language)
)
return
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, db_user.id, code)
if result["success"]:
await message.answer(
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
"expired": texts.PROMOCODE_EXPIRED,
"used": texts.PROMOCODE_USED,
"already_used_by_user": texts.PROMOCODE_USED,
"server_error": texts.ERROR
}
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
await message.answer(
error_text,
reply_markup=get_back_keyboard(db_user.language)
)
await state.clear()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_promocode_menu,
F.data == "menu_promocode"
)
dp.message.register(
process_promocode,
PromoCodeStates.waiting_for_code
)
+262
View File
@@ -0,0 +1,262 @@
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_referral_keyboard, get_back_keyboard
from app.localization.texts import get_texts
from app.utils.user_utils import get_user_referral_summary, get_detailed_referral_list, get_referral_analytics
logger = logging.getLogger(__name__)
async def show_referral_info(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
summary = await get_user_referral_summary(db, db_user.id)
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
referral_text = f"👥 <b>Реферальная программа</b>\n\n"
referral_text += f"📊 <b>Ваша статистика:</b>\n"
referral_text += f"• Приглашено пользователей: <b>{summary['invited_count']}</b>\n"
referral_text += f"• Сделали первое пополнение: <b>{summary['paid_referrals_count']}</b>\n"
referral_text += f"• Активных рефералов: <b>{summary['active_referrals_count']}</b>\n"
referral_text += f"• Конверсия: <b>{summary['conversion_rate']}%</b>\n"
referral_text += f"• Заработано всего: <b>{texts.format_price(summary['total_earned_kopeks'])}</b>\n"
referral_text += f"• За последний месяц: <b>{texts.format_price(summary['month_earned_kopeks'])}</b>\n\n"
referral_text += f"🎁 <b>Как работают награды:</b>\n"
referral_text += f"• Новый пользователь получает: <b>{texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}</b> при первом пополнении от <b>{texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}</b>\n"
referral_text += f"• Вы получаете при первом пополнении реферала: <b>{texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}</b>\n"
referral_text += f"• Комиссия с каждого пополнения реферала: <b>{settings.REFERRAL_COMMISSION_PERCENT}%</b>\n\n"
referral_text += f"🔗 <b>Ваша реферальная ссылка:</b>\n"
referral_text += f"<code>{referral_link}</code>\n\n"
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
if summary['recent_earnings']:
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(
referral_text,
reply_markup=get_referral_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
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 callback.message.edit_text(
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
])
)
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 callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
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 callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
]),
parse_mode="HTML"
)
await callback.answer()
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.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"
)]
])
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 callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_referral_info,
F.data == "menu_referrals"
)
dp.callback_query.register(
create_invite_message,
F.data == "referral_create_invite"
)
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"💰 Зачислено на баланс: {rubles_amount:.2f}\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} звезд → {rubles_amount:.2f}"
)
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 платежей")
+830
View File
@@ -0,0 +1,830 @@
import logging
from datetime import datetime
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
from app.config import settings
from app.states import RegistrationStates
from app.database.crud.user import (
get_user_by_telegram_id, create_user, get_user_by_referral_code
)
from app.database.models import UserStatus
from app.keyboards.inline import (
get_rules_keyboard, get_main_menu_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
logger = logging.getLogger(__name__)
async def handle_potential_referral_code(
message: types.Message,
state: FSMContext,
db: AsyncSession
):
current_state = await state.get_state()
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
if current_state not in [
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state,
None
]:
return False
user = await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status == UserStatus.ACTIVE.value:
return False
potential_code = message.text.strip()
if len(potential_code) < 4 or len(potential_code) > 20:
return False
referrer = await get_user_by_referral_code(db, potential_code)
if not referrer:
await message.answer(
"❌ Неверный реферальный код.\n\n"
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start"
)
return True
data = await state.get_data() or {}
data['referral_code'] = potential_code
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer("✅ Реферальный код принят!")
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', 'ru')
texts = get_texts(language)
await message.answer(
texts.RULES_TEXT,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 Правила отправлены после ввода реферального кода")
else:
await complete_registration(message, state, db)
return True
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
referral_code = None
if len(message.text.split()) > 1:
potential_code = message.text.split()[1]
referral_code = potential_code
logger.info(f"🔎 Найден реферальный код: {referral_code}")
if referral_code:
await state.set_data({'referral_code': referral_code})
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status != UserStatus.DELETED.value:
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
profile_updated = False
if user.username != message.from_user.username:
old_username = user.username
user.username = message.from_user.username
logger.info(f"📝 Username обновлен: '{old_username}''{user.username}'")
profile_updated = True
if user.first_name != message.from_user.first_name:
old_first_name = user.first_name
user.first_name = message.from_user.first_name
logger.info(f"📝 Имя обновлено: '{old_first_name}''{user.first_name}'")
profile_updated = True
if user.last_name != message.from_user.last_name:
old_last_name = user.last_name
user.last_name = message.from_user.last_name
logger.info(f"📝 Фамилия обновлена: '{old_last_name}''{user.last_name}'")
profile_updated = True
user.last_activity = datetime.utcnow()
if profile_updated:
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен")
else:
await db.commit()
texts = get_texts(user.language)
if referral_code and not user.referred_by_id:
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
has_active_subscription = user.subscription is not None
subscription_is_active = False
if user.subscription:
subscription_is_active = user.subscription.is_active
await message.answer(
texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
),
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
)
)
await state.clear()
return
if user and user.status == UserStatus.DELETED.value:
logger.info(f"🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию")
try:
from app.services.user_service import UserService
from app.database.models import (
Subscription, Transaction, PromoCodeUse,
ReferralEarning, SubscriptionServer
)
from sqlalchemy import delete
if user.subscription:
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
)
)
logger.info(f"🗑️ Удалены записи SubscriptionServer")
if user.subscription:
await db.delete(user.subscription)
logger.info(f"🗑️ Удалена подписка пользователя")
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
)
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
)
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
)
await db.execute(
delete(Transaction).where(Transaction.user_id == user.id)
)
user.status = UserStatus.ACTIVE.value
user.balance_kopeks = 0
user.remnawave_uuid = None
user.has_had_paid_subscription = False
user.referred_by_id = None
user.username = message.from_user.username
user.first_name = message.from_user.first_name
user.last_name = message.from_user.last_name
user.updated_at = datetime.utcnow()
user.last_activity = datetime.utcnow()
from app.utils.user_utils import generate_unique_referral_code
user.referral_code = await generate_unique_referral_code(db, user.telegram_id)
await db.commit()
logger.info(f"✅ Пользователь {user.telegram_id} подготовлен к восстановлению")
except Exception as e:
logger.error(f"❌ Ошибка подготовки к восстановлению: {e}")
await db.rollback()
else:
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
language = 'ru'
texts = get_texts(language)
data = await state.get_data() or {}
data['language'] = language
await state.set_data(data)
logger.info(f"💾 Установлен русский язык по умолчанию")
await message.answer(
texts.RULES_TEXT,
reply_markup=get_rules_keyboard(language)
)
logger.info(f"📋 Правила отправлены")
await state.set_state(RegistrationStates.waiting_for_rules_accept)
current_state = await state.get_state()
logger.info(f"📊 Установлено состояние: {current_state}")
async def process_rules_accept(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
logger.info(f"📋 RULES: Начало обработки правил")
logger.info(f"📊 Callback data: {callback.data}")
logger.info(f"👤 User: {callback.from_user.id}")
current_state = await state.get_state()
logger.info(f"📊 Текущее состояние: {current_state}")
try:
await callback.answer()
data = await state.get_data()
language = data.get('language', 'ru')
texts = get_texts(language)
if callback.data == 'rules_accept':
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
try:
await callback.message.delete()
logger.info(f"🗑️ Сообщение с правилами удалено")
except Exception as e:
logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}")
try:
await callback.message.edit_text(
"✅ Правила приняты! Завершаем регистрацию...",
reply_markup=None
)
except:
pass
if data.get('referral_code'):
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
await complete_registration_from_callback(callback, state, db)
else:
try:
await callback.message.answer(
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info(f"🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration_from_callback(callback, state, db)
else:
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
try:
rules_required_text = getattr(texts, 'RULES_REQUIRED',
"Для использования бота необходимо принять правила сервиса.")
await callback.message.edit_text(
rules_required_text,
reply_markup=get_rules_keyboard(language)
)
except Exception as e:
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
await callback.message.edit_text(
"Для использования бота необходимо принять правила сервиса.",
reply_markup=get_rules_keyboard(language)
)
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
except Exception as e:
logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True)
await callback.answer("❌ Произошла ошибка. Попробуйте еще раз.", show_alert=True)
try:
data = await state.get_data()
language = data.get('language', 'ru')
await callback.message.answer(
"Произошла ошибка. Попробуйте принять правила еще раз:",
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
except:
pass
async def process_referral_code_input(
message: types.Message,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
data = await state.get_data()
language = data.get('language', 'ru')
texts = get_texts(language)
referral_code = message.text.strip()
referrer = await get_user_by_referral_code(db, referral_code)
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer("✅ Реферальный код применен!")
logger.info(f"✅ Реферальный код применен")
else:
await message.answer("❌ Неверный реферальный код")
logger.info(f"❌ Неверный реферальный код")
return
await complete_registration(message, state, db)
async def process_referral_code_skip(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
logger.info(f"⭐️ SKIP: Пропуск реферального кода от пользователя {callback.from_user.id}")
await callback.answer()
try:
await callback.message.delete()
logger.info(f"🗑️ Сообщение с вопросом о реферальном коде удалено")
except Exception as e:
logger.warning(f"⚠️ Не удалось удалить сообщение с вопросом о реферальном коде: {e}")
try:
await callback.message.edit_text(
"✅ Завершаем регистрацию...",
reply_markup=None
)
except:
pass
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}")
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
texts = get_texts(existing_user.language)
data = await state.get_data()
if data.get('referral_code') and not existing_user.referred_by_id:
await callback.message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
has_active_subscription = existing_user.subscription is not None
subscription_is_active = False
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
user_name = existing_user.full_name
user_telegram_id = existing_user.telegram_id
user_language = existing_user.language
has_had_paid_subscription = existing_user.has_had_paid_subscription
balance_kopeks = existing_user.balance_kopeks
try:
await callback.message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status(existing_user, texts)
),
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await callback.message.answer(f"Добро пожаловать, {user_name}!")
await state.clear()
return
data = await state.get_data()
language = data.get('language', 'ru')
texts = get_texts(language)
referrer_id = data.get('referrer_id')
if not referrer_id and data.get('referral_code'):
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
referrer_id = referrer.id
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info(f"🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}")
existing_user.username = callback.from_user.username
existing_user.first_name = callback.from_user.first_name
existing_user.last_name = callback.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
user = existing_user
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
elif not existing_user:
logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}")
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
user = await create_user(
db=db,
telegram_id=callback.from_user.id,
username=callback.from_user.username,
first_name=callback.from_user.first_name,
last_name=callback.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code
)
else:
logger.info(f"🔄 Обновляем существующего пользователя {callback.from_user.id}")
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
user = existing_user
if referrer_id:
try:
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()
has_active_subscription = False
subscription_is_active = False
user_name = user.full_name
balance_kopeks = user.balance_kopeks
user_telegram_id = user.telegram_id
user_language = user.language
has_had_paid_subscription = user.has_had_paid_subscription
try:
await callback.message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status_simple(texts)
),
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
)
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке главного меню: {e}")
try:
balance_rubles = balance_kopeks / 100
await callback.message.answer(
f"Добро пожаловать, {user_name}!\n"
f"Баланс: {balance_rubles:.2f}\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,
balance_kopeks=balance_kopeks
)
)
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}")
async def complete_registration(
message: types.Message,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.")
texts = get_texts(existing_user.language)
data = await state.get_data()
if data.get('referral_code') and not existing_user.referred_by_id:
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
has_active_subscription = existing_user.subscription is not None
subscription_is_active = False
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
user_name = existing_user.full_name
user_telegram_id = existing_user.telegram_id
user_language = existing_user.language
has_had_paid_subscription = existing_user.has_had_paid_subscription
balance_kopeks = existing_user.balance_kopeks
try:
await message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status(existing_user, texts)
),
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await message.answer(f"Добро пожаловать, {user_name}!")
await state.clear()
return
data = await state.get_data()
language = data.get('language', 'ru')
texts = get_texts(language)
referrer_id = data.get('referrer_id')
if not referrer_id and data.get('referral_code'):
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
referrer_id = referrer.id
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info(f"🔄 Восстанавливаем удаленного пользователя {message.from_user.id}")
existing_user.username = message.from_user.username
existing_user.first_name = message.from_user.first_name
existing_user.last_name = message.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
user = existing_user
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
elif not existing_user:
logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}")
referral_code = await generate_unique_referral_code(db, message.from_user.id)
user = await create_user(
db=db,
telegram_id=message.from_user.id,
username=message.from_user.username,
first_name=message.from_user.first_name,
last_name=message.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code
)
else:
logger.info(f"🔄 Обновляем существующего пользователя {message.from_user.id}")
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
from datetime import datetime
existing_user.updated_at = datetime.utcnow()
existing_user.last_activity = datetime.utcnow()
await db.commit()
await db.refresh(existing_user)
user = existing_user
if referrer_id:
try:
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()
has_active_subscription = False
subscription_is_active = False
user_name = user.full_name
balance_kopeks = user.balance_kopeks
user_telegram_id = user.telegram_id
user_language = user.language
has_had_paid_subscription = user.has_had_paid_subscription
try:
await message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status_simple(texts)
),
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks
)
)
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке главного меню: {e}")
try:
balance_rubles = balance_kopeks / 100
await message.answer(
f"Добро пожаловать, {user_name}!\n"
f"Баланс: {balance_rubles:.2f}\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,
balance_kopeks=balance_kopeks
)
)
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}")
def _get_subscription_status(user, texts):
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):
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
def get_referral_code_keyboard(language: str):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="⭐️ Пропустить",
callback_data="referral_skip"
)]
])
def register_handlers(dp: Dispatcher):
logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===")
dp.message.register(
cmd_start,
Command("start")
)
logger.info("✅ Зарегистрирован cmd_start")
dp.callback_query.register(
process_rules_accept,
F.data.in_(["rules_accept", "rules_decline"]),
StateFilter(RegistrationStates.waiting_for_rules_accept)
)
logger.info("✅ Зарегистрирован process_rules_accept")
dp.callback_query.register(
process_referral_code_skip,
F.data == "referral_skip",
StateFilter(RegistrationStates.waiting_for_referral_code)
)
logger.info("✅ Зарегистрирован process_referral_code_skip")
dp.message.register(
process_referral_code_input,
StateFilter(RegistrationStates.waiting_for_referral_code)
)
logger.info("✅ Зарегистрирован process_referral_code_input")
dp.message.register(
handle_potential_referral_code,
StateFilter(
RegistrationStates.waiting_for_rules_accept,
RegistrationStates.waiting_for_referral_code
)
)
logger.info("✅ Зарегистрирован handle_potential_referral_code")
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_support_keyboard
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
async def show_support_info(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.SUPPORT_INFO,
reply_markup=get_support_keyboard(db_user.language)
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_support_info,
F.data == "menu_support"
)
+139
View File
@@ -0,0 +1,139 @@
import logging
from aiogram import types
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import get_db
from app.database.crud.user import get_user_by_id, add_user_balance
from app.database.crud.transaction import create_transaction, get_transaction_by_external_id
from app.database.models import TransactionType, PaymentMethod
from app.external.tribute import TributeService
logger = logging.getLogger(__name__)
async def tribute_webhook(request):
try:
signature = request.headers.get('X-Signature', '')
payload = await request.text()
tribute_service = TributeService()
if not tribute_service.verify_webhook_signature(payload, signature):
logger.warning("Неверная подпись Tribute webhook")
return web.Response(status=400, text="Invalid signature")
webhook_data = await request.json()
processed_data = await tribute_service.process_webhook(webhook_data)
if not processed_data:
logger.error("Ошибка обработки Tribute webhook")
return web.Response(status=400, text="Invalid webhook data")
async for db in get_db():
try:
existing_transaction = await get_transaction_by_external_id(
db, processed_data['payment_id'], PaymentMethod.TRIBUTE
)
if existing_transaction:
logger.info(f"Платеж {processed_data['payment_id']} уже обработан")
return web.Response(status=200, text="Already processed")
if processed_data['status'] == 'completed':
user = await get_user_by_id(db, processed_data['user_id'])
if user:
await add_user_balance(
db, user, processed_data['amount_kopeks'],
f"Пополнение через Tribute: {processed_data['payment_id']}"
)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=processed_data['amount_kopeks'],
description=f"Пополнение через Tribute",
payment_method=PaymentMethod.TRIBUTE,
external_id=processed_data['payment_id']
)
logger.info(f"✅ Обработан Tribute платеж: {processed_data['payment_id']}")
return web.Response(status=200, text="OK")
except Exception as e:
logger.error(f"Ошибка обработки Tribute webhook: {e}")
await db.rollback()
return web.Response(status=500, text="Internal error")
finally:
break
except Exception as e:
logger.error(f"Ошибка в Tribute webhook: {e}")
return web.Response(status=500, text="Internal error")
async def handle_successful_payment(message: types.Message):
try:
payment = message.successful_payment
payload_parts = payment.invoice_payload.split('_')
if len(payload_parts) >= 3 and payload_parts[0] == 'balance':
user_id = int(payload_parts[1])
amount_kopeks = int(payload_parts[2])
async for db in get_db():
try:
existing_transaction = await get_transaction_by_external_id(
db, payment.telegram_payment_charge_id, PaymentMethod.TELEGRAM_STARS
)
if existing_transaction:
logger.info(f"Stars платеж {payment.telegram_payment_charge_id} уже обработан")
return
user = await get_user_by_id(db, user_id)
if user:
await add_user_balance(
db, user, amount_kopeks,
f"Пополнение через Telegram Stars"
)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=f"Пополнение через Telegram Stars",
payment_method=PaymentMethod.TELEGRAM_STARS,
external_id=payment.telegram_payment_charge_id
)
await message.answer(
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
)
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
except Exception as e:
logger.error(f"Ошибка обработки Stars платежа: {e}")
await db.rollback()
finally:
break
except Exception as e:
logger.error(f"Ошибка в обработчике Stars платежа: {e}")
async def handle_pre_checkout_query(pre_checkout_query: types.PreCheckoutQuery):
try:
await pre_checkout_query.answer(ok=True)
logger.info(f"Pre-checkout query принят: {pre_checkout_query.id}")
except Exception as e:
logger.error(f"Ошибка в pre-checkout query: {e}")
await pre_checkout_query.answer(ok=False, error_message="Ошибка обработки платежа")
+611
View File
@@ -0,0 +1,611 @@
from typing import List, Optional
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from app.localization.texts import get_texts
def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=texts.ADMIN_USERS, callback_data="admin_users"),
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
],
[
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
],
[
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring"),
InlineKeyboardButton(text=texts.ADMIN_REFERRALS, callback_data="admin_referrals")
],
[
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave")
],
[
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics"),
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
]
])
def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="👥 Все пользователи", callback_data="admin_users_list"),
InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search")
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"),
InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📱 Все подписки", callback_data="admin_subs_list"),
InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring")
],
[
InlineKeyboardButton(text="⚙️ Настройки цен", callback_data="admin_subs_pricing"),
InlineKeyboardButton(text="🌍 Управление странами", callback_data="admin_subs_countries")
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🎫 Все промокоды", callback_data="admin_promo_list"),
InlineKeyboardButton(text=" Создать", callback_data="admin_promo_create")
],
[
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_promo_general_stats")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"promo_edit_{promo_id}"),
InlineKeyboardButton(text="🔄 Статус", callback_data=f"promo_toggle_{promo_id}")
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data=f"promo_stats_{promo_id}"),
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"promo_delete_{promo_id}")
],
[
InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list")
]
])
def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📨 Всем пользователям", callback_data="admin_msg_all"),
InlineKeyboardButton(text="🎯 По подпискам", callback_data="admin_msg_by_sub")
],
[
InlineKeyboardButton(text="🔍 По критериям", callback_data="admin_msg_custom"),
InlineKeyboardButton(text="📋 История", callback_data="admin_msg_history")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"),
InlineKeyboardButton(text="⏸️ Остановить", callback_data="admin_mon_stop")
],
[
InlineKeyboardButton(text="📊 Статус", callback_data="admin_mon_status"),
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs")
],
[
InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_mon_settings")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📊 Системная статистика", callback_data="admin_rw_system"),
InlineKeyboardButton(text="🖥️ Управление нодами", callback_data="admin_rw_nodes")
],
[
InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_rw_sync"),
InlineKeyboardButton(text="🌍 Управление сквадами", callback_data="admin_rw_squads")
],
[
InlineKeyboardButton(text="📈 Трафик", callback_data="admin_rw_traffic")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="👥 Пользователи", callback_data="admin_stats_users"),
InlineKeyboardButton(text="📱 Подписки", callback_data="admin_stats_subs")
],
[
InlineKeyboardButton(text="💰 Доходы", callback_data="admin_stats_revenue"),
InlineKeyboardButton(text="🤝 Рефералы", callback_data="admin_stats_referrals")
],
[
InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru") -> InlineKeyboardMarkup:
keyboard = [
[
InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}")
],
[
InlineKeyboardButton(text="⚙️ Настройка", callback_data=f"admin_user_servers_{user_id}"),
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
],
[
InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_transactions_{user_id}")
]
]
if user_status == "active":
keyboard.append([
InlineKeyboardButton(text="🚫 Заблокировать", callback_data=f"admin_user_block_{user_id}"),
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
])
elif user_status == "blocked":
keyboard.append([
InlineKeyboardButton(text="✅ Разблокировать", callback_data=f"admin_user_unblock_{user_id}"),
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
])
elif user_status == "deleted":
keyboard.append([
InlineKeyboardButton(text="❌ Пользователь удален", callback_data="noop")
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_confirmation_keyboard(
confirm_action: str,
cancel_action: str = "admin_panel",
language: str = "ru"
) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=texts.YES, callback_data=confirm_action),
InlineKeyboardButton(text=texts.NO, callback_data=cancel_action)
]
])
def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="💰 Баланс", callback_data="promo_type_balance"),
InlineKeyboardButton(text="📅 Дни подписки", callback_data="promo_type_days")
],
[
InlineKeyboardButton(text="🎁 Триал", callback_data="promo_type_trial")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")
]
])
def get_promocode_list_keyboard(promocodes: list, page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup:
keyboard = []
for promo in promocodes:
status_emoji = "" if promo.is_active else ""
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
keyboard.append([
InlineKeyboardButton(
text=f"{status_emoji} {type_emoji} {promo.code}",
callback_data=f"promo_manage_{promo.id}"
)
])
if total_pages > 1:
pagination_row = []
if page > 1:
pagination_row.append(
InlineKeyboardButton(text="⬅️", callback_data=f"admin_promo_list_page_{page - 1}")
)
pagination_row.append(
InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")
)
if page < total_pages:
pagination_row.append(
InlineKeyboardButton(text="➡️", callback_data=f"admin_promo_list_page_{page + 1}")
)
keyboard.append(pagination_row)
keyboard.extend([
[InlineKeyboardButton(text=" Создать", callback_data="admin_promo_create")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="👥 Всем", callback_data="broadcast_all"),
InlineKeyboardButton(text="📱 С подпиской", callback_data="broadcast_active")
],
[
InlineKeyboardButton(text="🎁 Триал", callback_data="broadcast_trial"),
InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub")
],
[
InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
]
])
def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📅 Сегодня", callback_data="criteria_today"),
InlineKeyboardButton(text="📅 За неделю", callback_data="criteria_week")
],
[
InlineKeyboardButton(text="📅 За месяц", callback_data="criteria_month"),
InlineKeyboardButton(text="⚡ Активные сегодня", callback_data="criteria_active_today")
],
[
InlineKeyboardButton(text="👤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
InlineKeyboardButton(text="👤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
],
[
InlineKeyboardButton(text="🤝 Через рефералов", callback_data="criteria_referrals"),
InlineKeyboardButton(text="🎫 Использовали промокоды", callback_data="criteria_promocodes")
],
[
InlineKeyboardButton(text="🎯 Прямая регистрация", callback_data="criteria_direct")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
]
])
def get_broadcast_history_keyboard(page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup:
keyboard = []
if total_pages > 1:
pagination_row = []
if page > 1:
pagination_row.append(
InlineKeyboardButton(text="⬅️", callback_data=f"admin_msg_history_page_{page - 1}")
)
pagination_row.append(
InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")
)
if page < total_pages:
pagination_row.append(
InlineKeyboardButton(text="➡️", callback_data=f"admin_msg_history_page_{page + 1}")
)
keyboard.append(pagination_row)
keyboard.extend([
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_msg_history")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
keyboard = [
[InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
[InlineKeyboardButton(text="🆕 Только новые", callback_data="sync_new_users")],
[InlineKeyboardButton(text="📈 Обновить данные", callback_data="sync_update_data")],
[
InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate"),
InlineKeyboardButton(text="🧹 Очистка", callback_data="sync_cleanup")
],
[InlineKeyboardButton(text="💡 Рекомендации", callback_data="sync_recommendations")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_confirmation_keyboard(sync_type: str, language: str = "ru") -> InlineKeyboardMarkup:
keyboard = [
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"confirm_{sync_type}")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rw_sync")]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_result_keyboard(sync_type: str, has_errors: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
keyboard = []
if has_errors:
keyboard.append([
InlineKeyboardButton(text="🔄 Повторить", callback_data=f"sync_{sync_type}")
])
if sync_type != "all_users":
keyboard.append([
InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")
])
keyboard.extend([
[
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_rw_system"),
InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate")
],
[InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")],
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="admin_remnawave")]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_period_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📅 Сегодня", callback_data="period_today"),
InlineKeyboardButton(text="📅 Вчера", callback_data="period_yesterday")
],
[
InlineKeyboardButton(text="📅 Неделя", callback_data="period_week"),
InlineKeyboardButton(text="📅 Месяц", callback_data="period_month")
],
[
InlineKeyboardButton(text="📅 Все время", callback_data="period_all")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")
]
])
def get_node_management_keyboard(node_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="▶️ Включить", callback_data=f"node_enable_{node_uuid}"),
InlineKeyboardButton(text="⏸️ Отключить", callback_data=f"node_disable_{node_uuid}")
],
[
InlineKeyboardButton(text="🔄 Перезагрузить", callback_data=f"node_restart_{node_uuid}"),
InlineKeyboardButton(text="📊 Статистика", callback_data=f"node_stats_{node_uuid}")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_nodes")
]
])
def get_squad_management_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="👥 Добавить всех пользователей", callback_data=f"squad_add_users_{squad_uuid}"),
],
[
InlineKeyboardButton(text="❌ Удалить всех пользователей", callback_data=f"squad_remove_users_{squad_uuid}"),
],
[
InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"squad_edit_{squad_uuid}"),
InlineKeyboardButton(text="🗑️ Удалить сквад", callback_data=f"squad_delete_{squad_uuid}")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_squads")
]
])
def get_squad_edit_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🔧 Изменить инбаунды", callback_data=f"squad_edit_inbounds_{squad_uuid}"),
],
[
InlineKeyboardButton(text="✏️ Переименовать", callback_data=f"squad_rename_{squad_uuid}"),
],
[
InlineKeyboardButton(text="⬅️ Назад к сквадам", callback_data=f"admin_squad_manage_{squad_uuid}")
]
])
def get_monitoring_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"),
InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop")
],
[
InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"),
InlineKeyboardButton(text="📝 Логи", callback_data="admin_mon_logs")
],
[
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"),
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
],
[
InlineKeyboardButton(text="⬅️ Назад в админку", callback_data="admin_panel")
]
])
def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
InlineKeyboardButton(text="🗑️ Очистить старые", callback_data="admin_mon_clear_logs")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")
]
])
def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync")
],
[
InlineKeyboardButton(text=" Добавить сервер", callback_data="admin_servers_add"),
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_servers_stats")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
]
])
def get_server_edit_keyboard(server_id: int, is_available: bool, language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server_id}"),
InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server_id}")
],
[
InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server_id}"),
InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server_id}")
],
[
InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server_id}")
],
[
InlineKeyboardButton(
text="❌ Отключить" if is_available else "✅ Включить",
callback_data=f"admin_server_toggle_{server_id}"
)
],
[
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server_id}"),
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
]
])
def get_admin_pagination_keyboard(
current_page: int,
total_pages: int,
callback_prefix: str,
back_callback: str = "admin_panel",
language: str = "ru"
) -> InlineKeyboardMarkup:
keyboard = []
if total_pages > 1:
row = []
if current_page > 1:
row.append(InlineKeyboardButton(
text="⬅️",
callback_data=f"{callback_prefix}_page_{current_page - 1}"
))
row.append(InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="current_page"
))
if current_page < total_pages:
row.append(InlineKeyboardButton(
text="➡️",
callback_data=f"{callback_prefix}_page_{current_page + 1}"
))
keyboard.append(row)
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_maintenance_keyboard(language: str = "ru", is_active: bool = False, monitoring_active: bool = False) -> InlineKeyboardMarkup:
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"
else:
toggle_text = "🔴 Выключить техработы" if is_active else "🔧 Включить техработы"
monitoring_text = "⏹️ Остановить мониторинг" if monitoring_active else "🔄 Запустить мониторинг"
check_api_text = "🔍 Проверить API"
back_text = "⬅️ Назад в админку"
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")]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
keyboard = [
[InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove
from typing import List
from app.localization.texts import get_texts
def get_main_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text=texts.MENU_BALANCE),
KeyboardButton(text=texts.MENU_SUBSCRIPTION)
],
[
KeyboardButton(text=texts.MENU_PROMOCODE),
KeyboardButton(text=texts.MENU_REFERRALS)
],
[
KeyboardButton(text=texts.MENU_SUPPORT),
KeyboardButton(text=texts.MENU_RULES)
]
],
resize_keyboard=True,
one_time_keyboard=False
)
def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text="👥 Пользователи"),
KeyboardButton(text="📱 Подписки")
],
[
KeyboardButton(text="🎫 Промокоды"),
KeyboardButton(text="📨 Рассылки")
],
[
KeyboardButton(text="📊 Статистика"),
KeyboardButton(text="🔧 Мониторинг")
],
[
KeyboardButton(text="🏠 Главное меню")
]
],
resize_keyboard=True,
one_time_keyboard=False
)
def get_cancel_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text=texts.CANCEL)]
],
resize_keyboard=True,
one_time_keyboard=True
)
def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text=texts.YES),
KeyboardButton(text=texts.NO)
]
],
resize_keyboard=True,
one_time_keyboard=True
)
def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="⏭️ Пропустить")]
],
resize_keyboard=True,
one_time_keyboard=True
)
def remove_keyboard() -> ReplyKeyboardRemove:
return ReplyKeyboardRemove()
def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="📱 Отправить контакт", request_contact=True)],
[KeyboardButton(text="❌ Отмена")]
],
resize_keyboard=True,
one_time_keyboard=True
)
def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)],
[KeyboardButton(text="❌ Отмена")]
],
resize_keyboard=True,
one_time_keyboard=True
)
+509
View File
@@ -0,0 +1,509 @@
import asyncio
from typing import Dict, Any
from app.config import settings
_cached_rules = {}
def _get_default_rules(language: str = "ru") -> str:
if language == "en":
return """
🔒 <b>Service Usage Rules</b>
1. It is forbidden to use the service for illegal activities
2. Copyright infringement is prohibited
3. Spam and malware distribution are prohibited
4. Using the service for DDoS attacks is prohibited
5. One account - one user
6. Refunds are made only in exceptional cases
7. Administration reserves the right to block an account for violating the rules
<b>By accepting the rules, you agree to comply with them.</b>
"""
else:
return """
📋 <b>Правила использования сервиса</b>
1. Запрещается использование сервиса для незаконной деятельности
2. Запрещается нарушение авторских прав
3. Запрещается спам и рассылка вредоносного ПО
4. Запрещается использование сервиса для DDoS атак
5. Один аккаунт - один пользователь
6. Возврат средств производится только в исключительных случаях
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
"""
class Texts:
def __init__(self, language: str = "ru"):
self.language = language
@property
def RULES_TEXT(self) -> str:
if self.language in _cached_rules:
return _cached_rules[self.language]
return _get_default_rules(self.language)
BACK = "⬅️ Назад"
CANCEL = "❌ Отмена"
CONFIRM = "✅ Подтвердить"
CONTINUE = "➡️ Продолжить"
YES = "✅ Да"
NO = "❌ Нет"
LOADING = "⏳ Загрузка..."
ERROR = "❌ Произошла ошибка"
SUCCESS = "✅ Успешно"
@staticmethod
def format_price(kopeks: int) -> str:
return f"{kopeks / 100:.2f}"
@staticmethod
def format_traffic(gb: float) -> str:
if gb == 0:
return "∞ (безлимит)"
elif gb >= 1024:
return f"{gb/1024:.1f} ТБ"
else:
return f"{gb:.0f} ГБ"
class RussianTexts(Texts):
def __init__(self):
super().__init__("ru")
WELCOME = """
🎉 <b>Добро пожаловать в VPN сервис!</b>
Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.
🔐 <b>Преимущества:</b>
Высокая скорость подключения
Серверы в разных странах
Надежная защита данных
Круглосуточная поддержка
Для начала работы выберите язык интерфейса:
"""
LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: <b>Русский</b>"
RULES_ACCEPT = "✅ Принимаю правила"
RULES_DECLINE = "❌ Не принимаю"
RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!"
REFERRAL_CODE_QUESTION = """
🤝 <b>У вас есть реферальный код от друга?</b>
Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!
Введите код или нажмите "Пропустить":
"""
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
MAIN_MENU = """👤 <b>{user_name}</b>
📱 <b>Подписка:</b> {subscription_status}
Выберите действие:
"""
MENU_BALANCE = "💰 Баланс"
MENU_SUBSCRIPTION = "📱 Подписка"
MENU_TRIAL = "🧪 Тестовая подписка"
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
MENU_PROMOCODE = "🎫 Промокод"
MENU_REFERRALS = "🤝 Рефералы"
MENU_SUPPORT = "🛠️ Техподдержка"
MENU_RULES = "📋 Правила сервиса"
MENU_LANGUAGE = "🌐 Язык"
MENU_ADMIN = "⚙️ Админ-панель"
BALANCE_BUTTON = "💰 Баланс: {balance}"
BALANCE_BUTTON_ZERO = "💰 Баланс: 0.00 ₽"
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
SUBSCRIPTION_ACTIVE = "✅ Активна"
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
SUBSCRIPTION_INFO = """
📱 <b>Информация о подписке</b>
📊 <b>Статус:</b> {status}
🎭 <b>Тип:</b> {type}
📅 <b>Действует до:</b> {end_date}
<b>Осталось дней:</b> {days_left}
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
🌍 <b>Серверы:</b> {countries_count} стран
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
💳 <b>Автоплатеж:</b> {autopay_status}
"""
TRIAL_AVAILABLE = """
🎁 <b>Тестовая подписка</b>
Вы можете получить бесплатную тестовую подписку:
<b>Период:</b> {days} дней
📈 <b>Трафик:</b> {traffic} ГБ
📱 <b>Устройства:</b> {devices} шт.
🌍 <b>Сервер:</b> 1 страна
Активировать тестовую подписку?
"""
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
BUY_SUBSCRIPTION_START = """
💎 <b>Настройка подписки</b>
Давайте настроим вашу подписку под ваши потребности.
Сначала выберите период подписки:
"""
SELECT_PERIOD = "Выберите период:"
SELECT_TRAFFIC = "Выберите пакет трафика:"
SELECT_COUNTRIES = "Выберите страны:"
SELECT_DEVICES = "Количество устройств:"
PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"
PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"
PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"
PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}"
PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}"
PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}"
TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"
TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"
TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"
TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"
TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"
TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}"
TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}"
SUBSCRIPTION_SUMMARY = """
📋 <b>Итоговая конфигурация</b>
📅 <b>Период:</b> {period} дней
📈 <b>Трафик:</b> {traffic}
🌍 <b>Страны:</b> {countries}
📱 <b>Устройства:</b> {devices}
💰 <b>Итого к оплате:</b> {total_price}
Подтвердить покупку?
"""
INSUFFICIENT_BALANCE = "❌ Недостаточно средств на балансе. Пополните баланс и попробуйте снова."
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
BALANCE_INFO = """
💰 <b>Баланс: {balance}</b>
Выберите действие:
"""
BALANCE_HISTORY = "📊 История операций"
BALANCE_TOP_UP = "💳 Пополнить"
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
TOP_UP_METHODS = """
💳 <b>Выберите способ оплаты</b>
Сумма: {amount}
"""
TOP_UP_STARS = "⭐ Telegram Stars"
TOP_UP_TRIBUTE = "💎 Банковская карта"
PROMOCODE_ENTER = "🎫 Введите промокод:"
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
PROMOCODE_INVALID = "❌ Неверный промокод"
PROMOCODE_EXPIRED = "❌ Промокод истек"
PROMOCODE_USED = "❌ Промокод уже использован"
REFERRAL_INFO = """
🤝 <b>Реферальная программа</b>
👥 <b>Приглашено:</b> {referrals_count} друзей
💰 <b>Заработано:</b> {earned_amount}
🔗 <b>Ваша реферальная ссылка:</b>
<code>{referral_link}</code>
🎫 <b>Ваш промокод:</b>
<code>{referral_code}</code>
💰 <b>Условия:</b>
За каждого друга: {registration_bonus}
Процент с пополнений: {commission_percent}%
"""
REFERRAL_INVITE_MESSAGE = """
🎯 <b>Приглашение в VPN сервис</b>
Привет! Приглашаю тебя в отличный VPN сервис!
🎁 По моей ссылке ты получишь бонус: {bonus}
🔗 Переходи: {link}
🎫 Или используй промокод: {code}
💪 Быстро, надежно, недорого!
"""
CREATE_INVITE = "📝 Создать приглашение"
TRIAL_ENDING_SOON = """
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через несколько часов.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
30 дней всего за {price}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
Успейте оформить до окончания тестового периода!
"""
MAINTENANCE_MODE_ACTIVE = """
🔧 Технические работы!
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
Ориентировочное время завершения: неизвестно
🔄 Попробуйте позже
Приносим извинения за временные неудобства.
"""
MAINTENANCE_MODE_API_ERROR = """
🔧 Технические работы!
Сервис временно недоступен из-за проблем с подключением к серверам.
Мы работаем над восстановлением. Попробуйте через несколько минут.
🔄 Последняя проверка: {last_check}
"""
SUBSCRIPTION_EXPIRING_PAID = """
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {end_date}.
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
SUBSCRIPTION_EXPIRED = """
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
AUTOPAY_SUCCESS = """
<b>Автоплатеж выполнен</b>
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
Новая дата окончания: {new_end_date}
"""
AUTOPAY_FAILED = """
<b>Ошибка автоплатежа</b>
Не удалось списать средства для продления подписки.
💰 Ваш баланс: {balance}
💳 Требуется: {required}
Пополните баланс и продлите подписку вручную.
"""
SUPPORT_INFO = f"""
🛠 <b>Техническая поддержка</b>
По всем вопросам обращайтесь к нашей поддержке:
👤 {settings.SUPPORT_USERNAME}
Мы поможем с:
Настройкой подключения
Решением технических проблем
Вопросами по оплате
Другими вопросами
Время ответа: обычно в течение 1-2 часов
"""
CONTACT_SUPPORT = "💬 Написать в поддержку"
ADMIN_PANEL = """
<b>Административная панель</b>
Выберите раздел для управления:
"""
ADMIN_USERS = "👥 Пользователи"
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
ADMIN_PROMOCODES = "🎫 Промокоды"
ADMIN_MESSAGES = "📨 Рассылки"
ADMIN_MONITORING = "🔍 Мониторинг"
ADMIN_REFERRALS = "🤝 Рефералы"
ADMIN_RULES = "📋 Правила"
ADMIN_REMNAWAVE = "🖥️ Remnawave"
ADMIN_STATISTICS = "📊 Статистика"
ACCESS_DENIED = "❌ Доступ запрещен"
USER_NOT_FOUND = "❌ Пользователь не найден"
SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена"
INVALID_AMOUNT = "❌ Неверная сумма"
OPERATION_CANCELLED = "❌ Операция отменена"
SUBSCRIPTION_EXPIRING = """
<b>Подписка истекает!</b>
Ваша подписка истекает через {days} дней.
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
"""
SUBSCRIPTION_EXPIRED = """
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
"""
AUTOPAY_SUCCESS = """
<b>Автоплатеж выполнен</b>
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
"""
AUTOPAY_FAILED = """
<b>Ошибка автоплатежа</b>
Не удалось списать средства для продления подписки.
Недостаточно средств на балансе: {balance}
Требуется: {required}
Пополните баланс и продлите подписку вручную.
"""
class EnglishTexts(Texts):
def __init__(self):
super().__init__("en")
WELCOME = """
🎉 <b>Welcome to VPN Service!</b>
Our service provides fast and secure internet access without restrictions.
🔐 <b>Advantages:</b>
High connection speed
Servers in different countries
Reliable data protection
24/7 support
To get started, select interface language:
"""
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
BACK = "⬅️ Back"
CANCEL = "❌ Cancel"
CONFIRM = "✅ Confirm"
CONTINUE = "➡️ Continue"
YES = "✅ Yes"
NO = "❌ No"
MENU_BALANCE = "💰 Balance"
MENU_SUBSCRIPTION = "📱 Subscription"
MENU_TRIAL = "🎁 Trial subscription"
LANGUAGES = {
"ru": RussianTexts,
"en": EnglishTexts
}
def get_texts(language: str = "ru") -> Texts:
return LANGUAGES.get(language, RussianTexts)()
async def get_rules_from_db(language: str = "ru") -> str:
try:
from app.database.database import get_db
from app.database.crud.rules import get_current_rules_content
async for db in get_db():
rules = await get_current_rules_content(db, language)
if rules:
_cached_rules[language] = rules
return rules
break
except Exception as e:
print(f"Ошибка получения правил из БД: {e}")
default_rules = _get_default_rules(language)
_cached_rules[language] = default_rules
return default_rules
def get_rules_sync(language: str = "ru") -> str:
try:
if language in _cached_rules:
return _cached_rules[language]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
rules = loop.run_until_complete(get_rules_from_db(language))
loop.close()
return rules
except Exception as e:
print(f"Ошибка получения правил: {e}")
return _get_default_rules(language)
async def refresh_rules_cache(language: str = "ru"):
try:
if language in _cached_rules:
del _cached_rules[language]
await get_rules_from_db(language)
print(f"✅ Кеш правил для языка {language} обновлен")
except Exception as e:
print(f"Ошибка обновления кеша правил: {e}")
def clear_rules_cache():
global _cached_rules
_cached_rules.clear()
print("✅ Кеш правил очищен")
+169
View File
@@ -0,0 +1,169 @@
import logging
from datetime import datetime
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware
from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser
from aiogram.fsm.context import FSMContext
from app.config import settings
from app.database.database import get_db
from app.database.crud.user import get_user_by_telegram_id, create_user
from app.states import RegistrationStates
logger = logging.getLogger(__name__)
class AuthMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
user: TgUser = None
if isinstance(event, (Message, CallbackQuery)):
user = event.from_user
if not user:
return await handler(event, data)
if user.is_bot:
return await handler(event, data)
async for db in get_db():
try:
db_user = await get_user_by_telegram_id(db, user.id)
if not db_user:
state: FSMContext = data.get('state')
current_state = None
if state:
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_rules_accept,
RegistrationStates.waiting_for_referral_code
]
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']))
)
if is_registration_process:
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
data['db'] = db
data['db_user'] = None
data['is_admin'] = False
return await handler(event, data)
else:
if isinstance(event, Message):
await event.answer(
"▶️ Для начала работы необходимо выполнить команду /start"
)
elif isinstance(event, CallbackQuery):
await event.answer(
"▶️ Необходимо начать с команды /start",
show_alert=True
)
logger.info(f"🚫 Заблокирован незарегистрированный пользователь {user.id}")
return
else:
from app.database.models import UserStatus
if db_user.status == UserStatus.BLOCKED.value:
if isinstance(event, Message):
await event.answer("🚫 Ваш аккаунт заблокирован администратором.")
elif isinstance(event, CallbackQuery):
await event.answer("🚫 Ваш аккаунт заблокирован администратором.", show_alert=True)
logger.info(f"🚫 Заблокированный пользователь {user.id} попытался использовать бота")
return
if db_user.status == UserStatus.DELETED.value:
state: FSMContext = data.get('state')
current_state = None
if state:
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_rules_accept,
RegistrationStates.waiting_for_referral_code
]
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']))
)
if is_start_or_registration:
logger.info(f"🔄 Удаленный пользователь {user.id} начинает повторную регистрацию")
data['db'] = db
data['db_user'] = None
data['is_admin'] = False
return await handler(event, data)
else:
if isinstance(event, Message):
await event.answer(
"❌ Ваш аккаунт был удален.\n"
"🔄 Для повторной регистрации выполните команду /start"
)
elif isinstance(event, CallbackQuery):
await event.answer(
"❌ Ваш аккаунт был удален. Для повторной регистрации выполните /start",
show_alert=True
)
logger.info(f"❌ Удаленный пользователь {user.id} попытался использовать бота без /start")
return
profile_updated = False
if db_user.username != user.username:
old_username = db_user.username
db_user.username = user.username
logger.info(f"📝 [Middleware] Username обновлен для {user.id}: '{old_username}''{db_user.username}'")
profile_updated = True
if db_user.first_name != user.first_name:
old_first_name = db_user.first_name
db_user.first_name = user.first_name
logger.info(f"📝 [Middleware] Имя обновлено для {user.id}: '{old_first_name}''{db_user.first_name}'")
profile_updated = True
if db_user.last_name != user.last_name:
old_last_name = db_user.last_name
db_user.last_name = user.last_name
logger.info(f"📝 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}''{db_user.last_name}'")
profile_updated = True
db_user.last_activity = datetime.utcnow()
if profile_updated:
db_user.updated_at = datetime.utcnow()
logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware")
await db.commit()
data['db'] = db
data['db_user'] = db_user
data['is_admin'] = settings.is_admin(user.id)
return await handler(event, data)
except Exception as e:
logger.error(f"Ошибка в AuthMiddleware: {e}")
logger.error(f"Event type: {type(event)}")
if hasattr(event, 'data'):
logger.error(f"Callback data: {event.data}")
await db.rollback()
raise
+42
View File
@@ -0,0 +1,42 @@
import logging
import time
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware
from aiogram.types import Message, CallbackQuery, TelegramObject
logger = logging.getLogger(__name__)
class LoggingMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
start_time = time.time()
try:
if isinstance(event, Message):
user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}"
text = event.text or event.caption or "[медиа]"
logger.info(f"📩 Сообщение от {user_info}: {text}")
elif isinstance(event, CallbackQuery):
user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}"
logger.info(f"🔘 Callback от {user_info}: {event.data}")
result = await handler(event, data)
execution_time = time.time() - start_time
if execution_time > 1.0:
logger.warning(f"⏱️ Медленная операция: {execution_time:.2f}s")
return result
except Exception as e:
execution_time = time.time() - start_time
logger.error(f"❌ Ошибка при обработке события за {execution_time:.2f}s: {e}")
raise
+45
View File
@@ -0,0 +1,45 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware
from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser
from app.config import settings
from app.services.maintenance_service import maintenance_service
logger = logging.getLogger(__name__)
class MaintenanceMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
user: TgUser = None
if isinstance(event, (Message, CallbackQuery)):
user = event.from_user
if not user or user.is_bot:
return await handler(event, data)
if not maintenance_service.is_maintenance_active():
return await handler(event, data)
if settings.is_admin(user.id):
return await handler(event, data)
maintenance_message = maintenance_service.get_maintenance_message()
try:
if isinstance(event, Message):
await event.answer(maintenance_message, parse_mode="HTML")
elif isinstance(event, CallbackQuery):
await event.answer(maintenance_message, show_alert=True)
except Exception as e:
logger.error(f"Ошибка отправки сообщения о техработах пользователю {user.id}: {e}")
logger.info(f"🔧 Пользователь {user.id} заблокирован во время техработ")
return
+53
View File
@@ -0,0 +1,53 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from datetime import datetime
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
from app.database.database import get_db
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import SubscriptionStatus
logger = logging.getLogger(__name__)
class SubscriptionStatusMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
telegram_id = None
if isinstance(event, (Message, CallbackQuery)):
telegram_id = event.from_user.id
elif isinstance(event, Update):
if event.message:
telegram_id = event.message.from_user.id
elif event.callback_query:
telegram_id = event.callback_query.from_user.id
if telegram_id:
try:
async for db in get_db():
user = await get_user_by_telegram_id(db, telegram_id)
if user and user.subscription:
current_time = datetime.utcnow()
subscription = user.subscription
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = current_time
await db.commit()
logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)")
break
except Exception as e:
logger.error(f"Ошибка проверки статуса подписки для пользователя {telegram_id}: {e}")
return await handler(event, data)
+53
View File
@@ -0,0 +1,53 @@
import asyncio
import logging
import time
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware
from aiogram.types import Message, CallbackQuery, TelegramObject
logger = logging.getLogger(__name__)
class ThrottlingMiddleware(BaseMiddleware):
def __init__(self, rate_limit: float = 0.5):
self.rate_limit = rate_limit
self.user_buckets: Dict[int, float] = {}
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
user_id = None
if isinstance(event, (Message, CallbackQuery)):
user_id = event.from_user.id
if not user_id:
return await handler(event, data)
now = time.time()
last_call = self.user_buckets.get(user_id, 0)
if now - last_call < self.rate_limit:
logger.warning(f"🚫 Throttling для пользователя {user_id}")
if isinstance(event, Message):
await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!")
elif isinstance(event, CallbackQuery):
await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True)
return
self.user_buckets[user_id] = now
cleanup_threshold = now - 60
self.user_buckets = {
uid: timestamp
for uid, timestamp in self.user_buckets.items()
if timestamp > cleanup_threshold
}
return await handler(event, data)
+3
View File
@@ -0,0 +1,3 @@
"""
Сервисы бизнес-логики
"""
+406
View File
@@ -0,0 +1,406 @@
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from dataclasses import dataclass
from app.config import settings
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
from app.utils.cache import cache
logger = logging.getLogger(__name__)
@dataclass
class MaintenanceStatus:
is_active: bool
enabled_at: Optional[datetime] = None
last_check: Optional[datetime] = None
reason: Optional[str] = None
auto_enabled: bool = False
api_status: bool = True
consecutive_failures: int = 0
class MaintenanceService:
def __init__(self):
self._status = MaintenanceStatus(is_active=False)
self._check_task: Optional[asyncio.Task] = None
self._is_checking = False
self._max_consecutive_failures = 3
self._bot = None
self._last_notification_sent = None
def set_bot(self, bot):
"""Устанавливает ссылку на бота для отправки уведомлений"""
self._bot = bot
logger.info("Бот установлен для maintenance_service")
@property
def status(self) -> MaintenanceStatus:
return self._status
def is_maintenance_active(self) -> bool:
return self._status.is_active
def get_maintenance_message(self) -> str:
if self._status.auto_enabled:
return f"""
🔧 Технические работы!
Сервис временно недоступен из-за проблем с подключением к серверам.
Мы работаем над восстановлением. Попробуйте через несколько минут.
🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
"""
else:
return settings.get_maintenance_message()
async def _notify_admins(self, message: str, alert_type: str = "info"):
"""Отправка уведомлений администраторам"""
if not self._bot:
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
return
cache_key = f"maintenance_notification_{alert_type}"
if await cache.get(cache_key):
return
admin_ids = settings.get_admin_ids()
if not admin_ids:
logger.warning("Список администраторов пуст")
return
emoji_map = {
"error": "🚨",
"warning": "⚠️",
"success": "",
"info": ""
}
emoji = emoji_map.get(alert_type, "")
formatted_message = f"{emoji} <b>Maintenance Service</b>\n\n{message}"
success_count = 0
for admin_id in admin_ids:
try:
await self._bot.send_message(
chat_id=admin_id,
text=formatted_message,
parse_mode="HTML"
)
success_count += 1
await asyncio.sleep(0.1)
except Exception as e:
logger.error(f"Ошибка отправки уведомления админу {admin_id}: {e}")
if success_count > 0:
logger.info(f"Уведомление отправлено {success_count} администраторам")
await cache.set(cache_key, True, expire=300)
else:
logger.error("Не удалось отправить уведомления ни одному администратору")
async def enable_maintenance(self, reason: Optional[str] = None, auto: bool = False) -> bool:
try:
if self._status.is_active:
logger.warning("Режим техработ уже включен")
return True
self._status.is_active = True
self._status.enabled_at = datetime.utcnow()
self._status.reason = reason or ("Автоматическое включение" if auto else "Включено администратором")
self._status.auto_enabled = auto
await self._save_status_to_cache()
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")
logger.warning(f"🔧 Режим техработ ВКЛЮЧЕН. Причина: {self._status.reason}")
return True
except Exception as e:
logger.error(f"Ошибка включения режима техработ: {e}")
return False
async def disable_maintenance(self) -> bool:
try:
if not self._status.is_active:
logger.info("Режим техработ уже выключен")
return True
was_auto = self._status.auto_enabled
duration = None
if self._status.enabled_at:
duration = datetime.utcnow() - self._status.enabled_at
self._status.is_active = False
self._status.enabled_at = None
self._status.reason = None
self._status.auto_enabled = False
self._status.consecutive_failures = 0
await self._save_status_to_cache()
duration_str = ""
if duration:
hours = int(duration.total_seconds() // 3600)
minutes = int((duration.total_seconds() % 3600) // 60)
if hours > 0:
duration_str = f"\n⏱️ <b>Длительность:</b> {hours}ч {minutes}мин"
else:
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
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")
logger.info("✅ Режим техработ ВЫКЛЮЧЕН")
return True
except Exception as e:
logger.error(f"Ошибка выключения режима техработ: {e}")
return False
async def start_monitoring(self) -> bool:
try:
if self._check_task and not self._check_task.done():
logger.warning("Мониторинг уже запущен")
return True
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()}с)")
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")
return True
except Exception as e:
logger.error(f"Ошибка запуска мониторинга: {e}")
return False
async def stop_monitoring(self) -> bool:
try:
if self._check_task and not self._check_task.done():
self._check_task.cancel()
try:
await self._check_task
except asyncio.CancelledError:
pass
await self._notify_admins("Мониторинг технических работ остановлен", "info")
logger.info("ℹ️ Мониторинг API остановлен")
return True
except Exception as e:
logger.error(f"Ошибка остановки мониторинга: {e}")
return False
async def check_api_status(self) -> bool:
try:
if self._is_checking:
return self._status.api_status
self._is_checking = True
self._status.last_check = datetime.utcnow()
api = RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY)
async with api:
is_connected = await test_api_connection(api)
if is_connected:
# API восстановилось
if not self._status.api_status:
await self._notify_admins(f"""
API RemnaWave восстановлено!
<b>Статус:</b> Доступно
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
API снова отвечает на запросы.
""", "success")
self._status.api_status = True
self._status.consecutive_failures = 0
if self._status.is_active and self._status.auto_enabled:
await self.disable_maintenance()
logger.info("✅ API восстановился, режим техработ автоматически отключен")
return True
else:
was_available = self._status.api_status
self._status.api_status = False
self._status.consecutive_failures += 1
if was_available:
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")
if (self._status.consecutive_failures >= self._max_consecutive_failures and
not self._status.is_active and
settings.is_maintenance_auto_enable()):
await self.enable_maintenance(
reason=f"Автоматическое включение после {self._status.consecutive_failures} неудачных проверок API",
auto=True
)
return False
except Exception as e:
logger.error(f"Ошибка проверки API: {e}")
if self._status.api_status:
await self._notify_admins(f"""
Ошибка при проверке API RemnaWave
<b>Ошибка:</b> {str(e)}
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
Не удалось выполнить проверку доступности API.
""", "error")
self._status.api_status = False
self._status.consecutive_failures += 1
return False
finally:
self._is_checking = False
await self._save_status_to_cache()
async def _monitoring_loop(self):
while True:
try:
await self.check_api_status()
await asyncio.sleep(settings.get_maintenance_check_interval())
except asyncio.CancelledError:
logger.info("Мониторинг отменен")
break
except Exception as e:
logger.error(f"Ошибка в цикле мониторинга: {e}")
await asyncio.sleep(30)
async def _save_status_to_cache(self):
try:
status_data = {
"is_active": self._status.is_active,
"enabled_at": self._status.enabled_at.isoformat() if self._status.enabled_at else None,
"reason": self._status.reason,
"auto_enabled": self._status.auto_enabled,
"consecutive_failures": self._status.consecutive_failures,
"last_check": self._status.last_check.isoformat() if self._status.last_check else None
}
await cache.set("maintenance_status", status_data, expire=3600)
except Exception as e:
logger.error(f"Ошибка сохранения состояния в кеш: {e}")
async def _load_status_from_cache(self):
try:
status_data = await cache.get("maintenance_status")
if not status_data:
return
self._status.is_active = status_data.get("is_active", False)
self._status.reason = status_data.get("reason")
self._status.auto_enabled = status_data.get("auto_enabled", False)
self._status.consecutive_failures = status_data.get("consecutive_failures", 0)
if status_data.get("enabled_at"):
self._status.enabled_at = datetime.fromisoformat(status_data["enabled_at"])
if status_data.get("last_check"):
self._status.last_check = datetime.fromisoformat(status_data["last_check"])
logger.info(f"🔥 Состояние техработ загружено из кеша: активен={self._status.is_active}")
except Exception as e:
logger.error(f"Ошибка загрузки состояния из кеша: {e}")
def get_status_info(self) -> Dict[str, Any]:
return {
"is_active": self._status.is_active,
"enabled_at": self._status.enabled_at,
"last_check": self._status.last_check,
"reason": self._status.reason,
"auto_enabled": self._status.auto_enabled,
"api_status": self._status.api_status,
"consecutive_failures": self._status.consecutive_failures,
"monitoring_active": self._check_task is not None and not self._check_task.done(),
"auto_enable_configured": settings.is_maintenance_auto_enable(),
"check_interval": settings.get_maintenance_check_interval(),
"bot_connected": self._bot is not None
}
async def force_api_check(self) -> Dict[str, Any]:
start_time = datetime.utcnow()
try:
api_status = await self.check_api_status()
end_time = datetime.utcnow()
response_time = (end_time - start_time).total_seconds()
return {
"success": True,
"api_available": api_status,
"response_time": round(response_time, 2),
"checked_at": end_time,
"consecutive_failures": self._status.consecutive_failures
}
except Exception as e:
end_time = datetime.utcnow()
response_time = (end_time - start_time).total_seconds()
return {
"success": False,
"api_available": False,
"error": str(e),
"response_time": round(response_time, 2),
"checked_at": end_time,
"consecutive_failures": self._status.consecutive_failures
}
maintenance_service = MaintenanceService()
+746
View File
@@ -0,0 +1,746 @@
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional, Set
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.database import get_db
from app.database.crud.subscription import (
get_expired_subscriptions, get_expiring_subscriptions,
get_subscriptions_for_autopay, deactivate_subscription,
extend_subscription
)
from app.database.crud.user import (
get_user_by_id, get_inactive_users, delete_user,
subtract_user_balance
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
from app.services.subscription_service import SubscriptionService
from app.services.payment_service import PaymentService
from app.localization.texts import get_texts
from app.external.remnawave_api import (
RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
)
logger = logging.getLogger(__name__)
class MonitoringService:
def __init__(self, bot=None):
self.is_running = False
self.subscription_service = SubscriptionService()
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:
logger.warning("Мониторинг уже запущен")
return
self.is_running = True
logger.info("🔄 Запуск службы мониторинга")
while self.is_running:
try:
await self._monitoring_cycle()
await asyncio.sleep(settings.MONITORING_INTERVAL * 60)
except Exception as e:
logger.error(f"Ошибка в цикле мониторинга: {e}")
await asyncio.sleep(60)
def stop_monitoring(self):
self.is_running = False
logger.info("ℹ️ Мониторинг остановлен")
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)
await self._process_autopayments(db)
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
await self._log_monitoring_event(
db, "monitoring_cycle_completed",
"Цикл мониторинга успешно завершен",
{"timestamp": datetime.utcnow().isoformat()}
)
except Exception as e:
logger.error(f"Ошибка в цикле мониторинга: {e}")
await self._log_monitoring_event(
db, "monitoring_cycle_error",
f"Ошибка в цикле мониторинга: {str(e)}",
{"error": str(e)},
is_success=False
)
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)
for subscription in expired_subscriptions:
from app.database.crud.subscription import expire_subscription
await expire_subscription(db, subscription)
user = await get_user_by_id(db, subscription.user_id)
if user and user.remnawave_uuid:
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
if user and self.bot:
await self._send_subscription_expired_notification(user)
logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и статус изменен на 'expired'")
if expired_subscriptions:
await self._log_monitoring_event(
db, "expired_subscriptions_processed",
f"Обработано {len(expired_subscriptions)} истёкших подписок",
{"count": len(expired_subscriptions)}
)
except Exception as e:
logger.error(f"Ошибка проверки истёкших подписок: {e}")
async def update_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
) -> Optional[RemnaWaveUser]:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user or not user.remnawave_uuid:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
current_time = datetime.utcnow()
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
await db.commit()
is_active = False
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
async with self.api as api:
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
active_internal_squads=subscription.connected_squads
)
subscription.subscription_url = updated_user.subscription_url
await db.commit()
status_text = "активным" if is_active else "истёкшим"
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
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_{subscription.id}"
user_key = f"user_{user.telegram_id}_today"
if (notification_key in self._notified_users 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:
success = await self._send_subscription_expiring_notification(user, subscription, days)
if success:
self._notified_users.add(notification_key)
all_processed_users.add(user_key)
sent_count += 1
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
else:
logger.warning(f"❌ Не удалось отправить уведомление пользователю {user.telegram_id}")
if sent_count > 0:
await self._log_monitoring_event(
db, "expiring_notifications_sent",
f"Отправлено {sent_count} уведомлений об истечении через {days} дней",
{"days": days, "count": sent_count}
)
except Exception as e:
logger.error(f"Ошибка проверки истекающих подписок: {e}")
async def _check_trial_expiring_soon(self, db: AsyncSession):
try:
threshold_time = datetime.utcnow() + timedelta(hours=2)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == True,
Subscription.end_date <= threshold_time,
Subscription.end_date > datetime.utcnow()
)
)
)
trial_expiring = result.scalars().all()
for subscription in trial_expiring:
user = subscription.user
if not user:
continue
notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
if notification_key in self._notified_users:
continue
if self.bot:
success = await self._send_trial_ending_notification(user, subscription)
if success:
self._notified_users.add(notification_key)
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
if trial_expiring:
await self._log_monitoring_event(
db, "trial_expiring_notifications_sent",
f"Отправлено {len(trial_expiring)} уведомлений об окончании тестовых подписок",
{"count": len(trial_expiring)}
)
except Exception as e:
logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}")
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
current_time = datetime.utcnow()
threshold_date = current_time + timedelta(days=days_before)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == False,
Subscription.end_date > current_time,
Subscription.end_date <= 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)} платных подписок для уведомлений")
return subscriptions
async def _process_autopayments(self, db: AsyncSession):
try:
current_time = datetime.utcnow()
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.autopay_enabled == True,
Subscription.is_trial == False
)
)
)
all_autopay_subscriptions = result.scalars().all()
autopay_subscriptions = []
for sub in all_autopay_subscriptions:
days_before_expiry = (sub.end_date - current_time).days
if days_before_expiry <= sub.autopay_days_before:
autopay_subscriptions.append(sub)
processed_count = 0
failed_count = 0
for subscription in autopay_subscriptions:
user = subscription.user
if not user:
continue
renewal_cost = settings.PRICE_30_DAYS
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
if autopay_key in self._notified_users:
continue
if user.balance_kopeks >= renewal_cost:
success = await subtract_user_balance(
db, user, renewal_cost,
"Автопродление подписки"
)
if success:
await extend_subscription(db, subscription, 30)
await self.subscription_service.update_remnawave_user(db, subscription)
if self.bot:
await self._send_autopay_success_notification(user, renewal_cost, 30)
processed_count += 1
self._notified_users.add(autopay_key)
logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно")
else:
failed_count += 1
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
else:
failed_count += 1
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
if processed_count > 0 or failed_count > 0:
await self._log_monitoring_event(
db, "autopayments_processed",
f"Автоплатежи: успешно {processed_count}, неудачно {failed_count}",
{"processed": processed_count, "failed": failed_count}
)
except Exception as e:
logger.error(f"Ошибка обработки автоплатежей: {e}")
async def _send_subscription_expired_notification(self, user: User) -> bool:
try:
message = """
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")]
])
await self.bot.send_message(
user.telegram_id,
message,
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) -> bool:
try:
from app.utils.formatters import format_days_declension
texts = get_texts(user.language)
days_text = format_days_declension(days, user.language)
if subscription.autopay_enabled:
autopay_status = "✅ Включен - подписка продлится автоматически"
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
else:
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
message = f"""
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⏰ Продлить подписку", callback_data="subscription_extend")],
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
])
await self.bot.send_message(
user.telegram_id,
message,
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) -> bool:
try:
texts = get_texts(user.language)
message = f"""
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через 2 часа.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
Успейте оформить до окончания тестового периода!
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")]
])
await self.bot.send_message(
user.telegram_id,
message,
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:
texts = get_texts(user.language)
message = texts.AUTOPAY_SUCCESS.format(
days=days,
amount=settings.format_price(amount)
)
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}")
async def _send_autopay_failed_notification(self, user: User, balance: int, required: int):
try:
texts = get_texts(user.language)
message = texts.AUTOPAY_FAILED.format(
balance=settings.format_price(balance),
required=settings.format_price(required)
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
])
await self.bot.send_message(
user.telegram_id,
message,
parse_mode="HTML",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}")
async def _cleanup_inactive_users(self, db: AsyncSession):
try:
now = datetime.utcnow()
if now.hour != 3:
return
inactive_users = await get_inactive_users(db, settings.INACTIVE_USER_DELETE_MONTHS)
deleted_count = 0
for user in inactive_users:
if not user.subscription or not user.subscription.is_active:
success = await delete_user(db, user)
if success:
deleted_count += 1
if deleted_count > 0:
await self._log_monitoring_event(
db, "inactive_users_cleanup",
f"Удалено {deleted_count} неактивных пользователей",
{"deleted_count": deleted_count}
)
logger.info(f"🗑️ Удалено {deleted_count} неактивных пользователей")
except Exception as e:
logger.error(f"Ошибка очистки неактивных пользователей: {e}")
async def _sync_with_remnawave(self, db: AsyncSession):
try:
now = datetime.utcnow()
if now.minute != 0:
return
async with self.subscription_service.api as api:
system_stats = await api.get_system_stats()
await self._log_monitoring_event(
db, "remnawave_sync",
"Синхронизация с RemnaWave завершена",
{"stats": system_stats}
)
except Exception as e:
logger.error(f"Ошибка синхронизации с RemnaWave: {e}")
await self._log_monitoring_event(
db, "remnawave_sync_error",
f"Ошибка синхронизации с RemnaWave: {str(e)}",
{"error": str(e)},
is_success=False
)
async def _log_monitoring_event(
self,
db: AsyncSession,
event_type: str,
message: str,
data: Dict[str, Any] = None,
is_success: bool = True
):
try:
log_entry = MonitoringLog(
event_type=event_type,
message=message,
data=data or {},
is_success=is_success
)
db.add(log_entry)
await db.commit()
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
recent_events_result = await db.execute(
select(MonitoringLog)
.order_by(desc(MonitoringLog.created_at))
.limit(10)
)
recent_events = recent_events_result.scalars().all()
yesterday = datetime.utcnow() - timedelta(days=1)
events_24h_result = await db.execute(
select(MonitoringLog)
.where(MonitoringLog.created_at >= yesterday)
)
events_24h = events_24h_result.scalars().all()
successful_events = sum(1 for event in events_24h if event.is_success)
failed_events = sum(1 for event in events_24h if not event.is_success)
return {
"is_running": self.is_running,
"last_update": datetime.utcnow(),
"recent_events": [
{
"type": event.event_type,
"message": event.message,
"success": event.is_success,
"created_at": event.created_at
}
for event in recent_events
],
"stats_24h": {
"total_events": len(events_24h),
"successful": successful_events,
"failed": failed_events,
"success_rate": round(successful_events / len(events_24h) * 100, 1) if events_24h else 0
}
}
except Exception as e:
logger.error(f"Ошибка получения статуса мониторинга: {e}")
return {
"is_running": self.is_running,
"last_update": datetime.utcnow(),
"recent_events": [],
"stats_24h": {
"total_events": 0,
"successful": 0,
"failed": 0,
"success_rate": 0
}
}
async def force_check_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
try:
expired_subscriptions = await get_expired_subscriptions(db)
expired_count = 0
for subscription in expired_subscriptions:
await deactivate_subscription(db, subscription)
expired_count += 1
expiring_subscriptions = await get_expiring_subscriptions(db, 1)
expiring_count = len(expiring_subscriptions)
autopay_subscriptions = await get_subscriptions_for_autopay(db)
autopay_processed = 0
for subscription in autopay_subscriptions:
user = await get_user_by_id(db, subscription.user_id)
if user and user.balance_kopeks >= settings.PRICE_30_DAYS:
autopay_processed += 1
await self._log_monitoring_event(
db, "manual_check_subscriptions",
f"Принудительная проверка: истекло {expired_count}, истекает {expiring_count}, автоплатежей {autopay_processed}",
{
"expired": expired_count,
"expiring": expiring_count,
"autopay_ready": autopay_processed
}
)
return {
"expired": expired_count,
"expiring": expiring_count,
"autopay_ready": autopay_processed
}
except Exception as e:
logger.error(f"Ошибка принудительной проверки подписок: {e}")
return {"expired": 0, "expiring": 0, "autopay_ready": 0}
async def get_monitoring_logs(
self,
db: AsyncSession,
limit: int = 50,
event_type: Optional[str] = None
) -> List[Dict[str, Any]]:
try:
from sqlalchemy import select, desc
query = select(MonitoringLog).order_by(desc(MonitoringLog.created_at))
if event_type:
query = query.where(MonitoringLog.event_type == event_type)
query = query.limit(limit)
result = await db.execute(query)
logs = result.scalars().all()
return [
{
"id": log.id,
"event_type": log.event_type,
"message": log.message,
"data": log.data,
"is_success": log.is_success,
"created_at": log.created_at
}
for log in logs
]
except Exception as e:
logger.error(f"Ошибка получения логов мониторинга: {e}")
return []
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
try:
from sqlalchemy import delete
cutoff_date = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
)
deleted_count = result.rowcount
await db.commit()
logger.info(f"Удалено {deleted_count} старых записей логов")
return deleted_count
except Exception as e:
logger.error(f"Ошибка очистки логов: {e}")
return 0
monitoring_service = MonitoringService()
+441
View File
@@ -0,0 +1,441 @@
import logging
import hashlib
import hmac
from typing import Optional, Dict, Any
from datetime import datetime
from aiogram import Bot
from aiogram.types import LabeledPrice
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
logger = logging.getLogger(__name__)
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
async def create_stars_invoice(
self,
amount_kopeks: int,
description: str,
payload: Optional[str] = None
) -> str:
if not self.bot or not self.stars_service:
raise ValueError("Bot instance required for Stars payments")
try:
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=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} звезд (~{amount_rubles:.2f}₽)")
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: {rubles_amount:.2f}₽ ({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:
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} о пополнении на {rubles_amount:.2f}")
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
logger.info(
f"✅ Обработан Stars платеж: пользователь {user_id}, "
f"{stars_amount} звезд → {rubles_amount:.2f}"
)
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,
user_id: int,
amount_kopeks: int,
description: str,
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
if not self.yookassa_service:
logger.error("YooKassa сервис не инициализирован")
return None
try:
amount_rubles = amount_kopeks / 100
payment_metadata = metadata or {}
payment_metadata.update({
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup"
})
yookassa_response = await self.yookassa_service.create_payment(
amount=amount_rubles,
currency="RUB",
description=description,
metadata=payment_metadata,
receipt_email=receipt_email,
receipt_phone=receipt_phone
)
if not yookassa_response or yookassa_response.get("error"):
logger.error(f"Ошибка создания платежа YooKassa: {yookassa_response}")
return None
yookassa_created_at = None
if yookassa_response.get("created_at"):
try:
dt_with_tz = datetime.fromisoformat(
yookassa_response["created_at"].replace('Z', '+00:00')
)
yookassa_created_at = dt_with_tz.replace(tzinfo=None)
except Exception as e:
logger.warning(f"Не удалось парсить created_at: {e}")
yookassa_created_at = None
local_payment = await create_yookassa_payment(
db=db,
user_id=user_id,
yookassa_payment_id=yookassa_response["id"],
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=yookassa_response["status"],
confirmation_url=yookassa_response.get("confirmation_url"),
metadata_json=payment_metadata,
payment_method_type=None,
yookassa_created_at=yookassa_created_at,
test_mode=yookassa_response.get("test_mode", False)
)
logger.info(f"Создан платеж YooKassa {yookassa_response['id']} на {amount_rubles}₽ для пользователя {user_id}")
return {
"local_payment_id": local_payment.id,
"yookassa_payment_id": yookassa_response["id"],
"confirmation_url": yookassa_response.get("confirmation_url"),
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"status": yookassa_response["status"],
"created_at": local_payment.created_at
}
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa: {e}")
return None
async def process_yookassa_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
try:
from app.database.crud.yookassa import (
get_yookassa_payment_by_id,
update_yookassa_payment_status,
link_yookassa_payment_to_transaction
)
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType, PaymentMethod
payment_object = webhook_data.get("object", {})
yookassa_payment_id = payment_object.get("id")
status = payment_object.get("status")
paid = payment_object.get("paid", False)
if not yookassa_payment_id:
logger.error("Webhook без ID платежа")
return False
payment = await get_yookassa_payment_by_id(db, yookassa_payment_id)
if not payment:
logger.error(f"Платеж не найден в БД: {yookassa_payment_id}")
return False
captured_at = None
if status == "succeeded":
captured_at = datetime.utcnow()
updated_payment = await update_yookassa_payment_status(
db,
yookassa_payment_id,
status,
is_paid=paid,
is_captured=(status == "succeeded"),
captured_at=captured_at,
payment_method_type=payment_object.get("payment_method", {}).get("type")
)
if status == "succeeded" and paid and not updated_payment.transaction_id:
transaction = await create_transaction(
db,
user_id=updated_payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=updated_payment.amount_kopeks,
description=f"Пополнение через YooKassa ({yookassa_payment_id[:8]}...)",
payment_method=PaymentMethod.YOOKASSA,
external_id=yookassa_payment_id,
is_completed=True
)
await link_yookassa_payment_to_transaction(
db, yookassa_payment_id, transaction.id
)
user = await get_user_by_id(db, updated_payment.user_id)
if user:
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:
await self.bot.send_message(
user.telegram_id,
f"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(updated_payment.amount_kopeks)}\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}")
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
else:
logger.error(f"Пользователь с ID {updated_payment.user_id} не найден при пополнении баланса")
return False
return True
except Exception as e:
logger.error(f"Ошибка обработки YooKassa webhook: {e}", exc_info=True)
return False
async def _process_successful_yookassa_payment(
self,
db: AsyncSession,
payment: "YooKassaPayment"
) -> bool:
try:
transaction = await create_transaction(
db=db,
user_id=payment.user_id,
transaction_type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через YooKassa: {payment.description}",
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True
)
await link_yookassa_payment_to_transaction(
db=db,
yookassa_payment_id=payment.yookassa_payment_id,
transaction_id=transaction.id
)
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}")
logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: "
f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}")
if self.bot and user:
try:
await self._send_payment_success_notification(
user.telegram_id,
payment.amount_kopeks
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о платеже: {e}")
return True
except Exception as e:
logger.error(f"Ошибка обработки успешного платежа YooKassa {payment.yookassa_payment_id}: {e}")
return False
async def _send_payment_success_notification(
self,
telegram_id: int,
amount_kopeks: int
) -> None:
if not self.bot:
return
try:
message = (f"✅ <b>Платеж успешно завершен!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"💳 Способ: Банковская карта (YooKassa)\n\n"
f"Средства зачислены на ваш баланс!")
await self.bot.send_message(
chat_id=telegram_id,
text=message,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления пользователю {telegram_id}: {e}")
async def create_tribute_payment(
self,
amount_kopeks: int,
user_id: int,
description: str
) -> str:
if not settings.TRIBUTE_ENABLED:
raise ValueError("Tribute payments are disabled")
try:
payment_data = {
"amount": amount_kopeks,
"currency": "RUB",
"description": description,
"user_id": user_id,
"callback_url": f"{settings.WEBHOOK_URL}/tribute/callback"
}
payment_url = f"https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}"
logger.info(f"Создан Tribute платеж на {amount_kopeks/100}₽ для пользователя {user_id}")
return payment_url
except Exception as e:
logger.error(f"Ошибка создания Tribute платежа: {e}")
raise
def verify_tribute_webhook(
self,
data: dict,
signature: str
) -> bool:
if not settings.TRIBUTE_WEBHOOK_SECRET:
return False
try:
message = str(data).encode()
expected_signature = hmac.new(
settings.TRIBUTE_WEBHOOK_SECRET.encode(),
message,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
except Exception as e:
logger.error(f"Ошибка проверки Tribute webhook: {e}")
return False
async def process_successful_payment(
self,
payment_id: str,
amount_kopeks: int,
user_id: int,
payment_method: str
) -> bool:
try:
logger.info(f"Обработан успешный платеж: {payment_id}, {amount_kopeks/100}₽, {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка обработки платежа: {e}")
return False
+144
View File
@@ -0,0 +1,144 @@
import logging
from datetime import datetime
from typing import Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.promocode import (
get_promocode_by_code, use_promocode, check_user_promocode_usage,
create_promocode_use, get_promocode_use_by_user_and_code
)
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
from app.database.models import PromoCodeType, SubscriptionStatus, User, PromoCode
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
class PromoCodeService:
def __init__(self):
self.remnawave_service = RemnaWaveService()
self.subscription_service = SubscriptionService()
async def activate_promocode(
self,
db: AsyncSession,
user_id: int,
code: str
) -> Dict[str, Any]:
try:
user = await get_user_by_id(db, user_id)
if not user:
return {"success": False, "error": "user_not_found"}
promocode = await get_promocode_by_code(db, code)
if not promocode:
return {"success": False, "error": "not_found"}
if not promocode.is_valid:
if promocode.current_uses >= promocode.max_uses:
return {"success": False, "error": "used"}
else:
return {"success": False, "error": "expired"}
existing_use = await check_user_promocode_usage(db, user_id, promocode.id)
if existing_use:
return {"success": False, "error": "already_used_by_user"}
result_description = await self._apply_promocode_effects(db, user, promocode)
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
from app.utils.user_utils import mark_user_as_had_paid_subscription
await mark_user_as_had_paid_subscription(db, user)
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
await create_promocode_use(db, promocode.id, user_id)
promocode.current_uses += 1
await db.commit()
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
return {
"success": True,
"description": result_description
}
except Exception as e:
logger.error(f"Ошибка активации промокода {code} для пользователя {user_id}: {e}")
await db.rollback()
return {"success": False, "error": "server_error"}
async def _apply_promocode_effects(self, db: AsyncSession, user: User, promocode: PromoCode) -> str:
effects = []
if promocode.balance_bonus_kopeks > 0:
await add_user_balance(
db, user, promocode.balance_bonus_kopeks,
f"Бонус по промокоду {promocode.code}"
)
balance_bonus_rubles = promocode.balance_bonus_kopeks / 100
effects.append(f"💰 Баланс пополнен на {balance_bonus_rubles}")
if promocode.subscription_days > 0:
from app.config import settings
subscription = await get_subscription_by_user_id(db, user.id)
if subscription:
await extend_subscription(db, subscription, promocode.subscription_days)
await self.subscription_service.update_remnawave_user(db, subscription)
effects.append(f"⏰ Подписка продлена на {promocode.subscription_days} дней")
logger.info(f"✅ Подписка пользователя {user.telegram_id} продлена на {promocode.subscription_days} дней в RemnaWave с текущими сквадами")
else:
from app.database.crud.subscription import create_paid_subscription
trial_squads = []
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
trial_squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=promocode.subscription_days,
traffic_limit_gb=0,
device_limit=1,
connected_squads=trial_squads
)
await self.subscription_service.create_remnawave_user(db, new_subscription)
effects.append(f"🎉 Получена подписка на {promocode.subscription_days} дней")
logger.info(f"✅ Создана новая подписка для пользователя {user.telegram_id} на {promocode.subscription_days} дней с триал сквадом {trial_squads}")
if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value:
from app.database.crud.subscription import create_trial_subscription
from app.config import settings
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
trial_subscription = await create_trial_subscription(
db,
user.id,
duration_days=trial_days
)
await self.subscription_service.create_remnawave_user(db, trial_subscription)
effects.append(f"🎁 Активирована тестовая подписка на {trial_days} дней")
logger.info(f"✅ Создана триал подписка для пользователя {user.telegram_id} на {trial_days} дней")
else:
effects.append("ℹ️ У вас уже есть активная подписка")
return "\n".join(effects) if effects else "✅ Промокод активирован"
+263
View File
@@ -0,0 +1,263 @@
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, 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,
bot: Bot = None
):
try:
new_user = await get_user_by_id(db, new_user_id)
referrer = await get_user_by_id(db, referrer_id)
if not new_user or not referrer:
logger.error(f"Пользователи не найдены: new_user_id={new_user_id}, referrer_id={referrer_id}")
return False
if new_user.referred_by_id != referrer_id:
logger.error(f"Пользователь {new_user_id} не привязан к рефереру {referrer_id}")
return False
await create_referral_earning(
db=db,
user_id=referrer_id,
referral_id=new_user_id,
amount_kopeks=0,
reason="referral_registration_pending"
)
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)}\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:
logger.error(f"Ошибка обработки реферальной регистрации: {e}")
return False
async def process_referral_topup(
db: AsyncSession,
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 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
if not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
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}")
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)
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
await add_user_balance(
db, referrer, settings.REFERRAL_INVITER_BONUS_KOPEKS,
f"Бонус за первое пополнение реферала {user.full_name}",
bot=bot
)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user_id,
amount_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
reason="referral_first_topup"
)
logger.info(f"💰 Реферер {referrer.telegram_id} получил бонус {settings.REFERRAL_INVITER_BONUS_KOPEKS/100}")
if bot:
inviter_bonus_notification = (
f"💰 <b>Реферальная награда!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
f"🎁 Вы получили награду: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
f"📈 Теперь с каждого его пополнения вы будете получать {settings.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} некорректный!")
commission_percent = 10
else:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
commission_amount = int(purchase_amount_kopeks * commission_percent / 100)
if commission_amount > 0:
await add_user_balance(
db, referrer, commission_amount,
f"Комиссия {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",
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
await db.commit()
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
return True
except Exception as e:
logger.error(f"Ошибка обработки покупки реферала: {e}")
import traceback
logger.error(f"Полный traceback: {traceback.format_exc()}")
return False
File diff suppressed because it is too large Load Diff
+539
View File
@@ -0,0 +1,539 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import Subscription, User, SubscriptionStatus
from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, UserStatus,
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__)
class SubscriptionService:
def __init__(self):
self.api = RemnaWaveAPI(
base_url=settings.REMNAWAVE_API_URL,
api_key=settings.REMNAWAVE_API_KEY
)
async def create_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
) -> Optional[RemnaWaveUser]:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.error(f"Пользователь {subscription.user_id} не найден")
return None
validation_success = await self.validate_and_clean_subscription(db, subscription, user)
if not validation_success:
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
return None
async with self.api as api:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
remnawave_user = existing_users[0]
try:
await api.reset_user_devices(remnawave_user.uuid)
logger.info(f"🔧 Сброшены HWID устройства для пользователя {user.telegram_id}")
except Exception as hwid_error:
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
updated_user = await api.update_user(
uuid=remnawave_user.uuid,
status=UserStatus.ACTIVE,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
active_internal_squads=subscription.connected_squads
)
else:
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
username = f"user_{user.telegram_id}"
updated_user = await api.create_user(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=f"Bot user: {user.full_name}",
active_internal_squads=subscription.connected_squads
)
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
user.remnawave_uuid = updated_user.uuid
await db.commit()
logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}")
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка RemnaWave API: {e}")
return None
except Exception as e:
logger.error(f"Ошибка создания RemnaWave пользователя: {e}")
return None
async def update_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
) -> Optional[RemnaWaveUser]:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user or not user.remnawave_uuid:
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
return None
current_time = datetime.utcnow()
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = current_time
await db.commit()
is_actually_active = False
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
async with self.api as api:
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
active_internal_squads=subscription.connected_squads
)
subscription.subscription_url = updated_user.subscription_url
await db.commit()
status_text = "активным" if is_actually_active else "истёкшим"
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка RemnaWave API: {e}")
return None
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
async def disable_remnawave_user(self, user_uuid: str) -> bool:
try:
async with self.api as api:
await api.disable_user(user_uuid)
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
return True
except Exception as e:
logger.error(f"Ошибка отключения RemnaWave пользователя: {e}")
return False
async def revoke_subscription(
self,
db: AsyncSession,
subscription: Subscription
) -> Optional[str]:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user or not user.remnawave_uuid:
return None
async with self.api as api:
updated_user = await api.revoke_user_subscription(user.remnawave_uuid)
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
await db.commit()
logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}")
return updated_user.subscription_url
except Exception as e:
logger.error(f"Ошибка обновления ссылки подписки: {e}")
return None
async def get_subscription_info(self, short_uuid: str) -> Optional[dict]:
try:
async with self.api as api:
info = await api.get_subscription_info(short_uuid)
return info
except Exception as e:
logger.error(f"Ошибка получения информации о подписке: {e}")
return None
async def sync_subscription_usage(
self,
db: AsyncSession,
subscription: Subscription
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user or not user.remnawave_uuid:
return False
async with self.api as api:
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
if not remnawave_user:
return False
used_gb = self._bytes_to_gb(remnawave_user.used_traffic_bytes)
subscription.traffic_used_gb = used_gb
await db.commit()
logger.debug(f"Синхронизирован трафик для подписки {subscription.id}: {used_gb} ГБ")
return True
except Exception as e:
logger.error(f"Ошибка синхронизации трафика: {e}")
return False
async def calculate_subscription_price(
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}")
base_price = PERIOD_PRICES.get(period_days, 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}")
else:
server_prices.append(0)
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}")
return total_price, server_prices
async def calculate_renewal_price(
self,
subscription: Subscription,
period_days: int,
db: AsyncSession
) -> int:
try:
from app.config import PERIOD_PRICES
base_price = PERIOD_PRICES.get(period_days, 0)
servers_price, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads, db
)
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
total_price = base_price + servers_price + devices_price + traffic_price
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}")
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}")
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}")
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 validate_and_clean_subscription(
self,
db: AsyncSession,
subscription: Subscription,
user: User
) -> bool:
try:
needs_cleanup = False
if user.remnawave_uuid:
try:
async with self.api as api:
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
if not remnawave_user:
logger.warning(f"⚠️ Пользователь {user.telegram_id} имеет UUID {user.remnawave_uuid}, но не найден в панели")
needs_cleanup = True
else:
if remnawave_user.telegram_id != user.telegram_id:
logger.warning(f"⚠️ Несоответствие telegram_id для пользователя {user.telegram_id}")
needs_cleanup = True
except Exception as api_error:
logger.error(f"❌ Ошибка проверки пользователя в панели: {api_error}")
needs_cleanup = True
if subscription.remnawave_short_uuid and not user.remnawave_uuid:
logger.warning(f"⚠️ У подписки есть short_uuid, но у пользователя нет remnawave_uuid")
needs_cleanup = True
if needs_cleanup:
logger.info(f"🧹 Очищаем мусорные данные подписки для пользователя {user.telegram_id}")
subscription.remnawave_short_uuid = None
subscription.subscription_url = ""
subscription.connected_squads = []
user.remnawave_uuid = None
await db.commit()
logger.info(f"✅ Мусорные данные очищены для пользователя {user.telegram_id}")
return True
except Exception as e:
logger.error(f"❌ Ошибка валидации подписки для пользователя {user.telegram_id}: {e}")
await db.rollback()
return False
async def get_countries_price_by_uuids(
self,
country_uuids: List[str],
db: AsyncSession
) -> Tuple[int, List[int]]:
try:
from app.database.crud.server_squad import get_server_squad_by_uuid
total_price = 0
prices_list = []
for country_uuid in country_uuids:
server = await get_server_squad_by_uuid(db, country_uuid)
if server and server.is_available and not server.is_full:
price = server.price_kopeks
total_price += price
prices_list.append(price)
logger.debug(f"🏷️ Страна {server.display_name}: {price/100}")
else:
default_price = 0
total_price += default_price
prices_list.append(default_price)
logger.warning(f"⚠️ Сервер {country_uuid} недоступен, используем базовую цену: {default_price/100}")
logger.info(f"💰 Общая стоимость стран: {total_price/100}")
return total_price, prices_list
except Exception as e:
logger.error(f"Ошибка получения цен стран: {e}")
default_prices = [0] * len(country_uuids)
return sum(default_prices), default_prices
async def _get_countries_price(self, country_uuids: List[str], db: AsyncSession) -> int:
try:
total_price, _ = await self.get_countries_price_by_uuids(country_uuids, db)
return total_price
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:
return 0
return gb * 1024 * 1024 * 1024
def _bytes_to_gb(self, bytes_value: int) -> float:
if bytes_value == 0:
return 0.0
return bytes_value / (1024 * 1024 * 1024)
+349
View File
@@ -0,0 +1,349 @@
import logging
import hashlib
import hmac
import json
from typing import Optional, Dict, Any
from datetime import datetime
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import get_db
from app.database.models import Transaction, TransactionType, PaymentMethod
from app.database.crud.transaction import (
create_transaction, get_transaction_by_external_id, complete_transaction
)
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
from app.external.tribute import TributeService as TributeAPI
logger = logging.getLogger(__name__)
class TributeService:
def __init__(self, bot: Bot):
self.bot = bot
self.tribute_api = TributeAPI()
async def create_payment_link(
self,
user_id: int,
amount_kopeks: int,
description: str = "Пополнение баланса"
) -> Optional[str]:
if not settings.TRIBUTE_ENABLED:
logger.warning("Tribute платежи отключены")
return None
try:
payment_url = await self.tribute_api.create_payment_link(
user_id=user_id,
amount_kopeks=amount_kopeks,
description=description
)
if not payment_url:
return None
return payment_url
except Exception as e:
logger.error(f"Ошибка создания Tribute платежа: {e}")
return None
async def process_webhook(
self,
payload: str,
signature: Optional[str] = None
) -> 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:
logger.error("Некорректный JSON в Tribute webhook")
return {"status": "error", "reason": "invalid_json"}
logger.info(f"Получен Tribute webhook: {json.dumps(webhook_data, ensure_ascii=False)}")
processed_data = await self.tribute_api.process_webhook(webhook_data)
if not processed_data:
return {"status": "ignored", "reason": "invalid_data"}
event_type = processed_data.get("event_type", "payment")
status = processed_data.get("status")
if event_type == "payment" and status == "paid":
await self._handle_successful_payment(processed_data)
elif event_type == "payment" and status == "failed":
await self._handle_failed_payment(processed_data)
elif event_type == "refund":
await self._handle_refund(processed_data)
return {"status": "ok", "event": event_type}
async def _handle_successful_payment(self, payment_data: Dict[str, Any]):
"""Обработка успешного платежа - ПОЛНОСТЬЮ ПЕРЕРАБОТАННАЯ ВЕРСИЯ"""
try:
user_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}")
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)
if not user:
logger.error(f"Пользователь {user_id} не найден")
return
logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп")
transaction = await create_unique_tribute_transaction(
db=session,
user_id=user.id,
payment_id=payment_id,
amount_kopeks=amount_kopeks,
description=f"Пополнение через Tribute: {amount_kopeks/100}₽ (ID: {payment_id})"
)
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
await session.commit()
logger.info(f"Баланс пользователя {user_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
await self._send_success_notification(user_id, amount_kopeks)
logger.info(f"Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_id}")
break
except Exception as e:
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"]
payment_id = payment_data["payment_id"]
async for session in get_db():
transaction = await get_transaction_by_external_id(
session, f"donation_{payment_id}", PaymentMethod.TRIBUTE
)
if transaction:
transaction.description = f"{transaction.description} (платеж отклонен)"
await session.commit()
await self._send_failure_notification(user_id)
logger.info(f"Обработан неудачный Tribute платеж для пользователя {user_id}")
break
except Exception as e:
logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}")
async def _handle_refund(self, refund_data: Dict[str, Any]):
"""Обработка возврата"""
try:
user_id = refund_data["user_id"]
amount_kopeks = refund_data["amount_kopeks"]
payment_id = refund_data["payment_id"]
async for session in get_db():
await create_transaction(
db=session,
user_id=user_id,
type=TransactionType.REFUND,
amount_kopeks=-amount_kopeks,
description=f"Возврат Tribute платежа {payment_id}",
payment_method=PaymentMethod.TRIBUTE,
external_id=f"refund_{payment_id}",
is_completed=True
)
user = await get_user_by_telegram_id(session, user_id)
if user and user.balance_kopeks >= amount_kopeks:
user.balance_kopeks -= amount_kopeks
await session.commit()
await self._send_refund_notification(user_id, amount_kopeks)
logger.info(f"Обработан возврат Tribute: {amount_kopeks/100}₽ для пользователя {user_id}")
break
except Exception as e:
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"💳 Способ оплаты: Tribute\n"
f"🎉 Средства зачислены на баланс!\n\n"
f"Спасибо за оплату! 🙏"
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
])
await self.bot.send_message(
user_id,
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
async def _send_failure_notification(self, user_id: int):
"""Отправка уведомления о неудачном платеже"""
try:
text = (
"❌ **Платеж не прошел**\n\n"
"К сожалению, ваш платеж через Tribute был отклонен.\n\n"
"Возможные причины:\n"
"• Недостаточно средств на карте\n"
"• Технические проблемы банка\n"
"• Превышен лимит операций\n\n"
"Попробуйте еще раз или обратитесь в поддержку."
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="menu_balance")],
[InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")]
])
await self.bot.send_message(
user_id,
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}")
async def _send_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"💳 Способ: Tribute\n\n"
f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n"
f"Если у вас есть вопросы, обратитесь в поддержку."
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
])
await self.bot.send_message(
user_id,
text,
reply_markup=keyboard,
parse_mode="Markdown"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о возврате: {e}")
async def force_process_payment(
self,
payment_id: str,
user_id: int,
amount_kopeks: int,
description: str = "Принудительная обработка Tribute платежа"
) -> bool:
"""Принудительная обработка платежа (для отладки)"""
try:
logger.info(f"🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}")
async for session in get_db():
user = await get_user_by_telegram_id(session, user_id)
if not user:
logger.error(f"❌ Пользователь {user_id} не найден")
return False
external_id = f"force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}"
transaction = await create_transaction(
db=session,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description,
payment_method=PaymentMethod.TRIBUTE,
external_id=external_id,
is_completed=True
)
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
await session.commit()
logger.info(f"💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп")
await self._send_success_notification(user_id, amount_kopeks)
logger.info(f"✅ Принудительно обработан платеж {payment_id}")
return True
except Exception as e:
logger.error(f"❌ Ошибка принудительной обработки: {e}", exc_info=True)
return False
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
return await self.tribute_api.get_payment_status(payment_id)
async def create_refund(
self,
payment_id: str,
amount_kopeks: Optional[int] = None,
reason: str = "Возврат по запросу"
) -> Optional[Dict[str, Any]]:
return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason)
+498
View File
@@ -0,0 +1,498 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, update
from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
get_users_count, get_users_statistics, get_inactive_users,
add_user_balance, subtract_user_balance, update_user, delete_user
)
from app.database.crud.transaction import get_user_transactions_count
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import (
User, UserStatus, Subscription, Transaction, PromoCodeUse,
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory
)
from app.config import settings
logger = logging.getLogger(__name__)
class UserService:
async def get_user_profile(
self,
db: AsyncSession,
user_id: int
) -> Optional[Dict[str, Any]]:
try:
user = await get_user_by_id(db, user_id)
if not user:
return None
subscription = await get_subscription_by_user_id(db, user_id)
transactions_count = await get_user_transactions_count(db, user_id)
return {
"user": user,
"subscription": subscription,
"transactions_count": transactions_count,
"is_admin": settings.is_admin(user.telegram_id),
"registration_days": (datetime.utcnow() - user.created_at).days
}
except Exception as e:
logger.error(f"Ошибка получения профиля пользователя {user_id}: {e}")
return None
async def search_users(
self,
db: AsyncSession,
query: str,
page: int = 1,
limit: int = 20
) -> Dict[str, Any]:
try:
offset = (page - 1) * limit
users = await get_users_list(
db, offset=offset, limit=limit, search=query
)
total_count = await get_users_count(db, search=query)
total_pages = (total_count + limit - 1) // limit
return {
"users": users,
"current_page": page,
"total_pages": total_pages,
"total_count": total_count,
"has_next": page < total_pages,
"has_prev": page > 1
}
except Exception as e:
logger.error(f"Ошибка поиска пользователей: {e}")
return {
"users": [],
"current_page": 1,
"total_pages": 1,
"total_count": 0,
"has_next": False,
"has_prev": False
}
async def get_users_page(
self,
db: AsyncSession,
page: int = 1,
limit: int = 20,
status: Optional[UserStatus] = None
) -> Dict[str, Any]:
try:
offset = (page - 1) * limit
users = await get_users_list(
db, offset=offset, limit=limit, status=status
)
total_count = await get_users_count(db, status=status)
total_pages = (total_count + limit - 1) // limit
return {
"users": users,
"current_page": page,
"total_pages": total_pages,
"total_count": total_count,
"has_next": page < total_pages,
"has_prev": page > 1
}
except Exception as e:
logger.error(f"Ошибка получения страницы пользователей: {e}")
return {
"users": [],
"current_page": 1,
"total_pages": 1,
"total_count": 0,
"has_next": False,
"has_prev": False
}
async def update_user_balance(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
admin_id: int
) -> bool:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False
if amount_kopeks > 0:
await add_user_balance(db, user, amount_kopeks, description=description)
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}")
return True
else:
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
if success:
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}")
return success
except Exception as e:
logger.error(f"Ошибка изменения баланса пользователя: {e}")
return False
async def block_user(
self,
db: AsyncSession,
user_id: int,
admin_id: int,
reason: str = "Заблокирован администратором"
) -> bool:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован при блокировке")
except Exception as e:
logger.error(f"❌ Ошибка деактивации RemnaWave пользователя при блокировке: {e}")
if user.subscription:
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, user.subscription)
await update_user(db, user, status=UserStatus.BLOCKED.value)
logger.info(f"Админ {admin_id} заблокировал пользователя {user_id}: {reason}")
return True
except Exception as e:
logger.error(f"Ошибка блокировки пользователя: {e}")
return False
async def unblock_user(
self,
db: AsyncSession,
user_id: int,
admin_id: int
) -> bool:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False
await update_user(db, user, status=UserStatus.ACTIVE.value)
if user.subscription:
from datetime import datetime
from app.database.models import SubscriptionStatus
if user.subscription.end_date > datetime.utcnow():
user.subscription.status = SubscriptionStatus.ACTIVE.value
await db.commit()
await db.refresh(user.subscription)
logger.info(f"🔄 Подписка пользователя {user_id} восстановлена")
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, user.subscription)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} восстановлен при разблокировке")
except Exception as e:
logger.error(f"❌ Ошибка восстановления RemnaWave пользователя при разблокировке: {e}")
else:
logger.info(f"⏰ Подписка пользователя {user_id} истекла, восстановление невозможно")
logger.info(f"Админ {admin_id} разблокировал пользователя {user_id}")
return True
except Exception as e:
logger.error(f"Ошибка разблокировки пользователя: {e}")
return False
async def delete_user_account(
self,
db: AsyncSession,
user_id: int,
admin_id: int
) -> bool:
try:
user = await get_user_by_id(db, user_id)
if not user:
logger.warning(f"Пользователь {user_id} не найден для удаления")
return False
logger.info(f"🗑️ Начинаем полное удаление пользователя {user_id} (Telegram ID: {user.telegram_id})")
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f" ✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
except Exception as e:
logger.warning(f" ⚠️ Ошибка деактивации RemnaWave: {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:
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}")
if user.subscription:
try:
await db.execute(
delete(SubscriptionServer).where(
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:
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:
from sqlalchemy import update
referrals_result = await db.execute(
update(User)
.where(User.referred_by_id == user_id)
.values(referred_by_id=None)
)
if referrals_result.rowcount > 0:
logger.info(f" 🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
await db.flush()
except Exception as e:
logger.error(f" ❌ Ошибка очистки реферальных ссылок: {e}")
try:
await db.execute(
delete(User).where(User.id == user_id)
)
await db.commit()
logger.info(f" ✅ Пользователь {user_id} окончательно удален из базы")
except Exception as e:
logger.error(f" ❌ Ошибка финального удаления пользователя: {e}")
await db.rollback()
return False
logger.info(f"✅ Пользователь {user.telegram_id} (ID: {user_id}) полностью удален администратором {admin_id}")
return True
except Exception as e:
logger.error(f"❌ Критическая ошибка удаления пользователя {user_id}: {e}")
await db.rollback()
return False
async def get_user_statistics(self, db: AsyncSession) -> Dict[str, Any]:
try:
stats = await get_users_statistics(db)
return stats
except Exception as e:
logger.error(f"Ошибка получения статистики пользователей: {e}")
return {
"total_users": 0,
"active_users": 0,
"blocked_users": 0,
"new_today": 0,
"new_week": 0,
"new_month": 0
}
async def cleanup_inactive_users(
self,
db: AsyncSession,
months: int = None
) -> int:
try:
if months is None:
months = settings.INACTIVE_USER_DELETE_MONTHS
inactive_users = await get_inactive_users(db, months)
deleted_count = 0
for user in inactive_users:
success = await self.delete_user_account(db, user.id, 0)
if success:
deleted_count += 1
logger.info(f"Удалено {deleted_count} неактивных пользователей")
return deleted_count
except Exception as e:
logger.error(f"Ошибка очистки неактивных пользователей: {e}")
return 0
async def get_user_activity_summary(
self,
db: AsyncSession,
user_id: int
) -> Dict[str, Any]:
try:
user = await get_user_by_id(db, user_id)
if not user:
return {}
subscription = await get_subscription_by_user_id(db, user_id)
transactions_count = await get_user_transactions_count(db, user_id)
days_since_registration = (datetime.utcnow() - user.created_at).days
days_since_activity = (datetime.utcnow() - user.last_activity).days if user.last_activity else None
return {
"user_id": user.id,
"telegram_id": user.telegram_id,
"username": user.username,
"full_name": user.full_name,
"status": user.status,
"language": user.language,
"balance_kopeks": user.balance_kopeks,
"registration_date": user.created_at,
"last_activity": user.last_activity,
"days_since_registration": days_since_registration,
"days_since_activity": days_since_activity,
"has_subscription": subscription is not None,
"subscription_active": subscription.is_active if subscription else False,
"subscription_trial": subscription.is_trial if subscription else False,
"transactions_count": transactions_count,
"referrer_id": user.referred_by_id,
"referral_code": user.referral_code
}
except Exception as e:
logger.error(f"Ошибка получения сводки активности пользователя {user_id}: {e}")
return {}
async def get_users_by_criteria(
self,
db: AsyncSession,
criteria: Dict[str, Any]
) -> List[User]:
try:
status = criteria.get('status')
has_subscription = criteria.get('has_subscription')
is_trial = criteria.get('is_trial')
min_balance = criteria.get('min_balance', 0)
max_balance = criteria.get('max_balance')
days_inactive = criteria.get('days_inactive')
registered_after = criteria.get('registered_after')
registered_before = criteria.get('registered_before')
users = await get_users_list(db, offset=0, limit=10000, status=status)
filtered_users = []
for user in users:
if user.balance_kopeks < min_balance:
continue
if max_balance and user.balance_kopeks > max_balance:
continue
if registered_after and user.created_at < registered_after:
continue
if registered_before and user.created_at > registered_before:
continue
if days_inactive and user.last_activity:
inactive_threshold = datetime.utcnow() - timedelta(days=days_inactive)
if user.last_activity > inactive_threshold:
continue
filtered_users.append(user)
return filtered_users
except Exception as e:
logger.error(f"Ошибка получения пользователей по критериям: {e}")
return []
+194
View File
@@ -0,0 +1,194 @@
import uuid
import logging
import asyncio
from typing import Optional, Dict, Any, List
from yookassa import Configuration, Payment as YooKassaPayment
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
from yookassa.domain.common.confirmation_type import ConfirmationType
from app.config import settings
logger = logging.getLogger(__name__)
class YooKassaService:
def __init__(self,
shop_id: Optional[str] = None,
secret_key: Optional[str] = None,
configured_return_url: Optional[str] = None,
bot_username_for_default_return: Optional[str] = None):
shop_id = shop_id or getattr(settings, 'YOOKASSA_SHOP_ID', None)
secret_key = secret_key or getattr(settings, 'YOOKASSA_SECRET_KEY', None)
configured_return_url = configured_return_url or getattr(settings, 'YOOKASSA_RETURN_URL', None)
if not shop_id or not secret_key:
logger.warning(
"YooKassa SHOP_ID или SECRET_KEY не настроены в settings. "
"Функционал платежей будет ОТКЛЮЧЕН.")
self.configured = False
else:
try:
Configuration.configure(shop_id, secret_key)
self.configured = True
logger.info(
f"YooKassa SDK сконфигурирован для shop_id: {shop_id[:5]}...")
except Exception as e:
logger.error(f"Ошибка конфигурации YooKassa SDK: {e}",
exc_info=True)
self.configured = False
if configured_return_url:
self.return_url = configured_return_url
elif bot_username_for_default_return:
self.return_url = f"https://t.me/{bot_username_for_default_return}"
logger.info(
f"YOOKASSA_RETURN_URL не установлен, используем бота: {self.return_url}")
else:
self.return_url = "https://t.me/"
logger.warning(
f"КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. "
f"Используем заглушку: {self.return_url}. Платежи могут работать некорректно.")
logger.info(f"YooKassa Service return_url: {self.return_url}")
async def create_payment(
self,
amount: float,
currency: str,
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Создает платеж в YooKassa"""
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж.")
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt["email"] = receipt_email
elif receipt_phone:
customer_contact_for_receipt["phone"] = receipt_phone
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt["email"] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logger.error(
"КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.")
return {
"error": True,
"internal_message": "Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию."
}
try:
builder = PaymentRequestBuilder()
builder.set_amount({
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_capture(True)
builder.set_confirmation({
"type": ConfirmationType.REDIRECT,
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
receipt_items_list: List[Dict[str, Any]] = [{
"description": description[:128],
"quantity": "1.00",
"amount": {
"value": str(round(amount, 2)),
"currency": currency.upper()
},
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
}]
receipt_data_dict: Dict[str, Any] = {
"customer": customer_contact_for_receipt,
"items": receipt_items_list
}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
f"Создание платежа YooKassa (Idempotence-Key: {idempotence_key}). "
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
logger.info(
f"Ответ YooKassa Payment.create: ID={response.id}, Status={response.status}, Paid={response.paid}")
return {
"id": response.id,
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
"status": response.status,
"metadata": response.metadata,
"amount_value": float(response.amount.value),
"amount_currency": response.amount.currency,
"idempotence_key_used": idempotence_key,
"paid": response.paid,
"refundable": response.refundable,
"created_at": response.created_at.isoformat() if hasattr(
response.created_at, 'isoformat') else str(response.created_at),
"description_from_yk": response.description,
"test_mode": response.test if hasattr(response, 'test') else None
}
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa: {e}", exc_info=True)
return None
async def get_payment_info(
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно получить информацию о платеже.")
return None
try:
logger.info(f"Получение информации о платеже YooKassa ID: {payment_id_in_yookassa}")
loop = asyncio.get_running_loop()
payment_info_yk = await loop.run_in_executor(
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
if payment_info_yk:
logger.info(
f"Информация о платеже YooKassa {payment_id_in_yookassa}: "
f"Status={payment_info_yk.status}, Paid={payment_info_yk.paid}")
return {
"id": payment_info_yk.id,
"status": payment_info_yk.status,
"paid": payment_info_yk.paid,
"amount_value": float(payment_info_yk.amount.value),
"amount_currency": payment_info_yk.amount.currency,
"metadata": payment_info_yk.metadata,
"description": payment_info_yk.description,
"refundable": payment_info_yk.refundable,
"created_at": payment_info_yk.created_at.isoformat() if hasattr(
payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at),
"captured_at": payment_info_yk.captured_at.isoformat()
if payment_info_yk.captured_at and hasattr(
payment_info_yk.captured_at, 'isoformat') else None,
"payment_method_type": payment_info_yk.payment_method.type
if payment_info_yk.payment_method else None,
"test_mode": payment_info_yk.test if hasattr(payment_info_yk, 'test') else None
}
else:
logger.warning(f"Платеж не найден в YooKassa ID: {payment_id_in_yookassa}")
return None
except Exception as e:
logger.error(f"Ошибка получения информации о платеже YooKassa {payment_id_in_yookassa}: {e}",
exc_info=True)
return None
+87
View File
@@ -0,0 +1,87 @@
from aiogram.fsm.state import State, StatesGroup
class RegistrationStates(StatesGroup):
waiting_for_rules_accept = State()
waiting_for_referral_code = State()
class SubscriptionStates(StatesGroup):
selecting_period = State()
selecting_traffic = State()
selecting_countries = State()
selecting_devices = State()
confirming_purchase = State()
adding_countries = State()
adding_traffic = State()
adding_devices = State()
extending_subscription = State()
confirming_traffic_reset = State()
class BalanceStates(StatesGroup):
waiting_for_amount = State()
waiting_for_stars_payment = State()
waiting_for_support_request = State()
class PromoCodeStates(StatesGroup):
waiting_for_code = State()
waiting_for_referral_code = State()
class AdminStates(StatesGroup):
waiting_for_user_search = State()
editing_user_balance = State()
extending_subscription = State()
adding_traffic = State()
granting_subscription = State()
editing_user_subscription = State()
creating_promocode = State()
setting_promocode_type = State()
setting_promocode_value = State()
setting_promocode_uses = State()
setting_promocode_expiry = State()
waiting_for_broadcast_message = State()
confirming_broadcast = State()
editing_squad_price = State()
editing_traffic_price = State()
editing_device_price = State()
editing_user_devices = State()
editing_user_traffic = State()
editing_rules_page = State()
confirming_sync = State()
editing_server_name = State()
editing_server_price = State()
editing_server_country = State()
editing_server_limit = State()
editing_server_description = State()
creating_server_uuid = State()
creating_server_name = State()
creating_server_price = State()
creating_server_country = State()
class SupportStates(StatesGroup):
waiting_for_message = State()
class AutoPayStates(StatesGroup):
setting_autopay_days = State()
confirming_autopay_toggle = State()
class SquadCreateStates(StatesGroup):
waiting_for_name = State()
selecting_inbounds = State()
class SquadRenameStates(StatesGroup):
waiting_for_new_name = State()
+13
View File
@@ -0,0 +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'
]
+264
View File
@@ -0,0 +1,264 @@
import json
import logging
from typing import Any, Optional, Union
from datetime import datetime, timedelta
import redis.asyncio as redis
from app.config import settings
logger = logging.getLogger(__name__)
class CacheService:
def __init__(self):
self.redis_client: Optional[redis.Redis] = None
self._connected = False
async def connect(self):
try:
self.redis_client = redis.from_url(settings.REDIS_URL)
await self.redis_client.ping()
self._connected = True
logger.info("✅ Подключение к Redis кешу установлено")
except Exception as e:
logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}")
self._connected = False
async def disconnect(self):
if self.redis_client:
await self.redis_client.close()
self._connected = False
async def get(self, key: str) -> Optional[Any]:
if not self._connected:
return None
try:
value = await self.redis_client.get(key)
if value:
return json.loads(value)
return None
except Exception as e:
logger.error(f"Ошибка получения из кеша {key}: {e}")
return None
async def set(
self,
key: str,
value: Any,
expire: Union[int, timedelta] = None
) -> bool:
if not self._connected:
return False
try:
serialized_value = json.dumps(value, default=str)
if isinstance(expire, timedelta):
expire = int(expire.total_seconds())
await self.redis_client.set(key, serialized_value, ex=expire)
return True
except Exception as e:
logger.error(f"Ошибка записи в кеш {key}: {e}")
return False
async def delete(self, key: str) -> bool:
if not self._connected:
return False
try:
deleted = await self.redis_client.delete(key)
return deleted > 0
except Exception as e:
logger.error(f"Ошибка удаления из кеша {key}: {e}")
return False
async def exists(self, key: str) -> bool:
if not self._connected:
return False
try:
return await self.redis_client.exists(key)
except Exception as e:
logger.error(f"Ошибка проверки существования в кеше {key}: {e}")
return False
async def expire(self, key: str, seconds: int) -> bool:
if not self._connected:
return False
try:
return await self.redis_client.expire(key, seconds)
except Exception as e:
logger.error(f"Ошибка установки TTL для {key}: {e}")
return False
async def get_keys(self, pattern: str = "*") -> list:
if not self._connected:
return []
try:
keys = await self.redis_client.keys(pattern)
return [key.decode() if isinstance(key, bytes) else key for key in keys]
except Exception as e:
logger.error(f"Ошибка получения ключей по паттерну {pattern}: {e}")
return []
async def flush_all(self) -> bool:
if not self._connected:
return False
try:
await self.redis_client.flushall()
logger.info("🗑️ Кеш полностью очищен")
return True
except Exception as e:
logger.error(f"Ошибка очистки кеша: {e}")
return False
async def increment(self, key: str, amount: int = 1) -> Optional[int]:
if not self._connected:
return None
try:
return await self.redis_client.incrby(key, amount)
except Exception as e:
logger.error(f"Ошибка инкремента {key}: {e}")
return None
async def set_hash(self, name: str, mapping: dict, expire: int = None) -> bool:
if not self._connected:
return False
try:
await self.redis_client.hset(name, mapping=mapping)
if expire:
await self.redis_client.expire(name, expire)
return True
except Exception as e:
logger.error(f"Ошибка записи хеша {name}: {e}")
return False
async def get_hash(self, name: str, key: str = None) -> Optional[Union[dict, str]]:
if not self._connected:
return None
try:
if key:
value = await self.redis_client.hget(name, key)
return value.decode() if value else None
else:
hash_data = await self.redis_client.hgetall(name)
return {k.decode(): v.decode() for k, v in hash_data.items()}
except Exception as e:
logger.error(f"Ошибка получения хеша {name}: {e}")
return None
cache = CacheService()
def cache_key(*parts) -> str:
return ":".join(str(part) for part in parts)
async def cached_function(key: str, expire: int = 300):
def decorator(func):
async def wrapper(*args, **kwargs):
cache_result = await cache.get(key)
if cache_result is not None:
return cache_result
result = await func(*args, **kwargs)
await cache.set(key, result, expire)
return result
return wrapper
return decorator
class UserCache:
@staticmethod
async def get_user_data(user_id: int) -> Optional[dict]:
key = cache_key("user", user_id)
return await cache.get(key)
@staticmethod
async def set_user_data(user_id: int, data: dict, expire: int = 3600) -> bool:
key = cache_key("user", user_id)
return await cache.set(key, data, expire)
@staticmethod
async def delete_user_data(user_id: int) -> bool:
key = cache_key("user", user_id)
return await cache.delete(key)
@staticmethod
async def get_user_session(user_id: int, session_key: str) -> Optional[Any]:
key = cache_key("session", user_id, session_key)
return await cache.get(key)
@staticmethod
async def set_user_session(
user_id: int,
session_key: str,
data: Any,
expire: int = 1800
) -> bool:
key = cache_key("session", user_id, session_key)
return await cache.set(key, data, expire)
class SystemCache:
@staticmethod
async def get_system_stats() -> Optional[dict]:
return await cache.get("system:stats")
@staticmethod
async def set_system_stats(stats: dict, expire: int = 300) -> bool:
return await cache.set("system:stats", stats, expire)
@staticmethod
async def get_nodes_status() -> Optional[list]:
return await cache.get("remnawave:nodes")
@staticmethod
async def set_nodes_status(nodes: list, expire: int = 60) -> bool:
return await cache.set("remnawave:nodes", nodes, expire)
@staticmethod
async def get_daily_stats(date: str) -> Optional[dict]:
key = cache_key("stats", "daily", date)
return await cache.get(key)
@staticmethod
async def set_daily_stats(date: str, stats: dict) -> bool:
key = cache_key("stats", "daily", date)
return await cache.set(key, stats, 86400) # 24 часа
class RateLimitCache:
@staticmethod
async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool:
key = cache_key("rate_limit", user_id, action)
current = await cache.get(key)
if current is None:
await cache.set(key, 1, window)
return False
if current >= limit:
return True
await cache.increment(key)
return False
@staticmethod
async def reset_rate_limit(user_id: int, action: str) -> bool:
key = cache_key("rate_limit", user_id, action)
return await cache.delete(key)
+117
View File
@@ -0,0 +1,117 @@
import logging
import functools
from typing import Callable, Any
from aiogram import types
from aiogram.fsm.context import FSMContext
from app.config import settings
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
def admin_required(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(
event: types.Update,
*args,
**kwargs
) -> Any:
user = None
if isinstance(event, (types.Message, types.CallbackQuery)):
user = event.from_user
if not user or not settings.is_admin(user.id):
texts = get_texts()
if isinstance(event, types.Message):
await event.answer(texts.ACCESS_DENIED)
elif isinstance(event, types.CallbackQuery):
await event.answer(texts.ACCESS_DENIED, show_alert=True)
logger.warning(f"Попытка доступа к админской функции от {user.id if user else 'Unknown'}")
return
return await func(event, *args, **kwargs)
return wrapper
def error_handler(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(*args, **kwargs) -> Any:
try:
return await func(*args, **kwargs)
except Exception as e:
logger.error(f"Ошибка в {func.__name__}: {e}", exc_info=True)
event = None
db_user = kwargs.get('db_user')
for arg in args:
if isinstance(arg, (types.Message, types.CallbackQuery)):
event = arg
break
if event:
texts = get_texts(db_user.language if db_user else 'ru')
if isinstance(event, types.Message):
await event.answer(texts.ERROR)
elif isinstance(event, types.CallbackQuery):
await event.answer(texts.ERROR, show_alert=True)
return wrapper
def state_cleanup(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(*args, **kwargs) -> Any:
state = kwargs.get('state')
try:
return await func(*args, **kwargs)
except Exception as e:
if state and isinstance(state, FSMContext):
await state.clear()
raise e
return wrapper
def typing_action(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(
event: types.Update,
*args,
**kwargs
) -> Any:
if isinstance(event, types.Message):
await event.bot.send_chat_action(
chat_id=event.chat.id,
action="typing"
)
return await func(event, *args, **kwargs)
return wrapper
def rate_limit(rate: float = 1.0, key: str = None):
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(
event: types.Update,
*args,
**kwargs
) -> Any:
return await func(event, *args, **kwargs)
return wrapper
return decorator
+218
View File
@@ -0,0 +1,218 @@
from datetime import datetime, timedelta
from typing import Union, Optional
def format_datetime(dt: Union[datetime, str], format_str: str = "%d.%m.%Y %H:%M") -> str:
if isinstance(dt, str):
if dt == "now" or dt == "":
dt = datetime.now()
else:
try:
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
except (ValueError, AttributeError):
dt = datetime.now()
return dt.strftime(format_str)
def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str:
if isinstance(dt, str):
if dt == "now" or dt == "":
dt = datetime.now()
else:
try:
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
except (ValueError, AttributeError):
dt = datetime.now()
return dt.strftime(format_str)
def format_time_ago(dt: Union[datetime, str]) -> str:
if isinstance(dt, str):
if dt == "now" or dt == "":
dt = datetime.now()
else:
try:
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
except (ValueError, AttributeError):
dt = datetime.now()
now = datetime.utcnow()
diff = now - dt
if diff.days > 0:
if diff.days == 1:
return "вчера"
elif diff.days < 7:
return f"{diff.days} дн. назад"
elif diff.days < 30:
weeks = diff.days // 7
return f"{weeks} нед. назад"
elif diff.days < 365:
months = diff.days // 30
return f"{months} мес. назад"
else:
years = diff.days // 365
return f"{years} г. назад"
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} ч. назад"
elif diff.seconds > 60:
minutes = diff.seconds // 60
return f"{minutes} мин. назад"
else:
return "только что"
def format_days_declension(days: int, language: str = "ru") -> str:
if language != "ru":
return f"{days} day{'s' if days != 1 else ''}"
if days % 10 == 1 and days % 100 != 11:
return f"{days} день"
elif days % 10 in [2, 3, 4] and days % 100 not in [12, 13, 14]:
return f"{days} дня"
else:
return f"{days} дней"
def format_duration(seconds: int) -> str:
if seconds < 60:
return f"{seconds} сек."
minutes = seconds // 60
if minutes < 60:
return f"{minutes} мин."
hours = minutes // 60
if hours < 24:
return f"{hours} ч."
days = hours // 24
return f"{days} дн."
def format_bytes(bytes_value: int) -> str:
if bytes_value == 0:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
size = float(bytes_value)
unit_index = 0
while size >= 1024 and unit_index < len(units) - 1:
size /= 1024
unit_index += 1
if size == int(size):
return f"{int(size)} {units[unit_index]}"
else:
return f"{size:.1f} {units[unit_index]}"
def format_percentage(value: float, decimals: int = 1) -> str:
return f"{value:.{decimals}f}%"
def format_number(number: Union[int, float], separator: str = " ") -> str:
if isinstance(number, float):
integer_part = int(number)
decimal_part = number - integer_part
formatted_integer = f"{integer_part:,}".replace(",", separator)
if decimal_part > 0:
return f"{formatted_integer}.{decimal_part:.2f}".split('.')[0] + f".{str(decimal_part).split('.')[1][:2]}"
else:
return formatted_integer
else:
return f"{number:,}".replace(",", separator)
def format_price_range(min_price: int, max_price: int) -> str:
from app.config import settings
min_formatted = settings.format_price(min_price)
max_formatted = settings.format_price(max_price)
if min_price == max_price:
return min_formatted
else:
return f"{min_formatted} - {max_formatted}"
def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
if len(text) <= max_length:
return text
return text[:max_length - len(suffix)] + suffix
def format_username(username: Optional[str], user_id: int, full_name: Optional[str] = None) -> str:
if full_name:
return full_name
elif username:
return f"@{username}"
else:
return f"ID{user_id}"
def format_subscription_status(
is_active: bool,
is_trial: bool,
end_date: Union[datetime, str],
language: str = "ru"
) -> str:
if isinstance(end_date, str):
try:
end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
except (ValueError, AttributeError):
end_date = datetime.now()
if not is_active:
return "❌ Неактивна" if language == "ru" else "❌ Inactive"
if is_trial:
status = "🎁 Тестовая" if language == "ru" else "🎁 Trial"
else:
status = "✅ Активна" if language == "ru" else "✅ Active"
now = datetime.utcnow()
if end_date > now:
days_left = (end_date - now).days
if days_left > 0:
status += f" ({days_left} дн.)" if language == "ru" else f" ({days_left} days)"
else:
hours_left = (end_date - now).seconds // 3600
status += f" ({hours_left} ч.)" if language == "ru" else f" ({hours_left} hrs)"
else:
status = "⏰ Истекла" if language == "ru" else "⏰ Expired"
return status
def format_traffic_usage(used_gb: float, limit_gb: int, language: str = "ru") -> str:
if limit_gb == 0:
if language == "ru":
return f"{used_gb:.1f} ГБ / ∞"
else:
return f"{used_gb:.1f} GB / ∞"
percentage = (used_gb / limit_gb) * 100 if limit_gb > 0 else 0
if language == "ru":
return f"{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)"
else:
return f"{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)"
def format_boolean(value: bool, language: str = "ru") -> str:
if language == "ru":
return "✅ Да" if value else "❌ Нет"
else:
return "✅ Yes" if value else "❌ No"
+82
View File
@@ -0,0 +1,82 @@
from typing import List, TypeVar, Generic, Dict, Any
from math import ceil
T = TypeVar('T')
class PaginationResult(Generic[T]):
def __init__(
self,
items: List[T],
total_count: int,
page: int,
per_page: int
):
self.items = items
self.total_count = total_count
self.page = page
self.per_page = per_page
self.total_pages = ceil(total_count / per_page) if per_page > 0 else 1
self.has_prev = page > 1
self.has_next = page < self.total_pages
self.prev_page = page - 1 if self.has_prev else None
self.next_page = page + 1 if self.has_next else None
def paginate_list(
items: List[T],
page: int = 1,
per_page: int = 10
) -> PaginationResult[T]:
total_count = len(items)
start_index = (page - 1) * per_page
end_index = start_index + per_page
page_items = items[start_index:end_index]
return PaginationResult(
items=page_items,
total_count=total_count,
page=page,
per_page=per_page
)
def get_pagination_info(
total_count: int,
page: int = 1,
per_page: int = 10
) -> Dict[str, Any]:
total_pages = ceil(total_count / per_page) if per_page > 0 else 1
return {
"total_count": total_count,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"has_prev": page > 1,
"has_next": page < total_pages,
"prev_page": page - 1 if page > 1 else None,
"next_page": page + 1 if page < total_pages else None,
"offset": (page - 1) * per_page
}
def get_page_numbers(
current_page: int,
total_pages: int,
max_visible: int = 5
) -> List[int]:
if total_pages <= max_visible:
return list(range(1, total_pages + 1))
half_visible = max_visible // 2
start_page = max(1, current_page - half_visible)
end_page = min(total_pages, start_page + max_visible - 1)
if end_page - start_page + 1 < max_visible:
start_page = max(1, end_page - max_visible + 1)
return list(range(start_page, end_page + 1))
+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)
+97
View File
@@ -0,0 +1,97 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Subscription, User
logger = logging.getLogger(__name__)
async def ensure_single_subscription(db: AsyncSession, user_id: int) -> Optional[Subscription]:
result = await db.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
)
subscriptions = result.scalars().all()
if len(subscriptions) <= 1:
return subscriptions[0] if subscriptions else None
latest_subscription = subscriptions[0]
old_subscriptions = subscriptions[1:]
logger.warning(f"🚨 Обнаружено {len(subscriptions)} подписок у пользователя {user_id}. Удаляем {len(old_subscriptions)} старых.")
for old_sub in old_subscriptions:
await db.delete(old_sub)
logger.info(f"🗑️ Удалена подписка ID {old_sub.id} от {old_sub.created_at}")
await db.commit()
await db.refresh(latest_subscription)
logger.info(f"✅ Оставлена подписка ID {latest_subscription.id} от {latest_subscription.created_at}")
return latest_subscription
async def update_or_create_subscription(
db: AsyncSession,
user_id: int,
**subscription_data
) -> Subscription:
existing_subscription = await ensure_single_subscription(db, user_id)
if existing_subscription:
for key, value in subscription_data.items():
if hasattr(existing_subscription, key):
setattr(existing_subscription, key, value)
existing_subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing_subscription)
logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}")
return existing_subscription
else:
new_subscription = Subscription(
user_id=user_id,
**subscription_data
)
db.add(new_subscription)
await db.commit()
await db.refresh(new_subscription)
logger.info(f"🆕 Создана новая подписка ID {new_subscription.id}")
return new_subscription
async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int:
result = await db.execute(
select(Subscription.user_id)
.group_by(Subscription.user_id)
.having(func.count(Subscription.id) > 1)
)
users_with_duplicates = result.scalars().all()
total_deleted = 0
for user_id in users_with_duplicates:
subscriptions_result = await db.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
)
subscriptions = subscriptions_result.scalars().all()
for old_subscription in subscriptions[1:]:
await db.delete(old_subscription)
total_deleted += 1
logger.info(f"🗑️ Удалена дублирующаяся подписка ID {old_subscription.id} пользователя {user_id}")
await db.commit()
logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок")
return total_deleted
+298
View File
@@ -0,0 +1,298 @@
import logging
import secrets
import string
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.orm import selectinload
from app.database.models import User, ReferralEarning, Transaction, TransactionType
logger = logging.getLogger(__name__)
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
max_attempts = 10
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).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)
)
for row in earnings_by_type_result:
earnings_by_type[row.reason] = {
'count': row.count,
'total_amount_kopeks': row.total_amount
}
active_referrals_count = 0
for referral in referrals:
if referral.last_activity and referral.last_activity >= month_ago:
active_referrals_count += 1
return {
'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"Ошибка получения статистики рефералов для пользователя {user_id}: {e}")
return {
'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': []
}
+137
View File
@@ -0,0 +1,137 @@
import re
from typing import Optional, Union
from datetime import datetime
def validate_email(email: str) -> bool:
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def validate_phone(phone: str) -> bool:
pattern = r'^\+?[1-9]\d{1,14}$'
cleaned_phone = re.sub(r'[\s\-\(\)]', '', phone)
return re.match(pattern, cleaned_phone) is not None
def validate_telegram_username(username: str) -> bool:
if not username:
return False
username = username.lstrip('@')
pattern = r'^[a-zA-Z0-9_]{5,32}$'
return re.match(pattern, username) is not None
def validate_promocode(code: str) -> bool:
if not code or len(code) < 3 or len(code) > 20:
return False
return code.replace('_', '').replace('-', '').isalnum()
def validate_amount(amount_str: str, min_amount: float = 0, max_amount: float = float('inf')) -> Optional[float]:
try:
amount = float(amount_str.replace(',', '.'))
if min_amount <= amount <= max_amount:
return amount
return None
except (ValueError, TypeError):
return None
def validate_positive_integer(value: Union[str, int], max_value: int = None) -> Optional[int]:
try:
num = int(value)
if num > 0 and (max_value is None or num <= max_value):
return num
return None
except (ValueError, TypeError):
return None
def validate_date_string(date_str: str, date_format: str = "%Y-%m-%d") -> Optional[datetime]:
try:
return datetime.strptime(date_str, date_format)
except ValueError:
return None
def validate_url(url: str) -> bool:
pattern = r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$'
return re.match(pattern, url) is not None
def validate_uuid(uuid_str: str) -> bool:
pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
return re.match(pattern, uuid_str.lower()) is not None
def validate_traffic_amount(traffic_str: str) -> Optional[int]:
traffic_str = traffic_str.upper().strip()
if traffic_str in ['UNLIMITED', 'БЕЗЛИМИТ', '']:
return 0
units = {
'MB': 1,
'GB': 1024,
'TB': 1024 * 1024,
'МБ': 1,
'ГБ': 1024,
'ТБ': 1024 * 1024
}
for unit, multiplier in units.items():
if traffic_str.endswith(unit):
try:
value = float(traffic_str[:-len(unit)].strip())
return int(value * multiplier)
except ValueError:
break
try:
return int(float(traffic_str))
except ValueError:
return None
def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
try:
days_int = int(days)
if 1 <= days_int <= 3650:
return days_int
return None
except (ValueError, TypeError):
return None
def sanitize_html(text: str) -> str:
allowed_tags = ['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'code', 'pre']
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 = re.sub(r'<(?!/?(?:' + '|'.join(allowed_tags) + r')\b)[^>]*>', '', text)
return text
def validate_device_count(count: Union[str, int]) -> Optional[int]:
try:
count_int = int(count)
if 1 <= count_int <= 10:
return count_int
return None
except (ValueError, TypeError):
return None
def validate_referral_code(code: str) -> bool:
if not code:
return False
if code.startswith('ref') and len(code) > 3:
user_id_part = code[3:]
return user_id_part.isdigit()
return validate_promocode(code)
+295
View File
@@ -0,0 +1,295 @@
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Градиенты -->
<linearGradient id="mainGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="50%" style="stop-color:#764ba2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f093fb;stop-opacity:1" />
</linearGradient>
<linearGradient id="skinGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#d4a574;stop-opacity:1" />
<stop offset="100%" style="stop-color:#b8935f;stop-opacity:1" />
</linearGradient>
<linearGradient id="redSkinGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#e8b598;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d4a574;stop-opacity:1" />
</linearGradient>
<linearGradient id="clothesGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3a3a3a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#595959;stop-opacity:1" />
</linearGradient>
<linearGradient id="bottleGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2d5016;stop-opacity:0.8" />
<stop offset="100%" style="stop-color:#1a2f0d;stop-opacity:0.9" />
</linearGradient>
<!-- Фильтры -->
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="dropshadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
<feOffset dx="2" dy="4" result="offset" />
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Фон -->
<rect width="400" height="400" fill="#1a1a2e" rx="50"/>
<!-- Декоративные элементы фона -->
<circle cx="50" cy="80" r="30" fill="url(#mainGradient)" opacity="0.1"/>
<circle cx="350" cy="120" r="40" fill="#8B4513" opacity="0.1"/>
<!-- Волны RemnaWave в фоне -->
<g transform="translate(200,200)" opacity="0.2">
<path d="M-120,-100 Q-60,-130 0,-100 Q60,-70 120,-100"
stroke="url(#mainGradient)"
stroke-width="4"
fill="none">
<animate attributeName="d"
values="M-120,-100 Q-60,-130 0,-100 Q60,-70 120,-100;M-120,-100 Q-60,-70 0,-100 Q60,-130 120,-100;M-120,-100 Q-60,-130 0,-100 Q60,-70 120,-100"
dur="4s"
repeatCount="indefinite"/>
</path>
</g>
<!-- Главный персонаж - Бомж-алкаш -->
<g transform="translate(200,180)" filter="url(#dropshadow)">
<!-- Тело в рваной куртке -->
<ellipse cx="0" cy="30" rx="45" ry="60" fill="url(#clothesGradient)"/>
<!-- Рваные края одежды -->
<path d="M-45,70 L-40,75 L-35,70 L-30,75 L-25,70 L-20,75 L-15,70 L-10,75 L-5,70 L0,75 L5,70 L10,75 L15,70 L20,75 L25,70 L30,75 L35,70 L40,75 L45,70"
stroke="none" fill="#2a2a2a"/>
<!-- Заплатки -->
<rect x="-20" y="10" width="12" height="10" fill="#5a4a3a" opacity="0.7" rx="2"/>
<rect x="15" y="35" width="10" height="8" fill="#4a4a4a" opacity="0.8" rx="1"/>
<rect x="-10" y="45" width="8" height="12" fill="#6a5a4a" opacity="0.6" rx="1"/>
<!-- Шея -->
<rect x="-12" y="-25" width="24" height="20" fill="url(#redSkinGradient)" rx="8"/>
<!-- Голова (более человечная форма) -->
<ellipse cx="0" cy="-45" rx="28" ry="32" fill="url(#redSkinGradient)"/>
<!-- Сальные волосы -->
<path d="M-25,-70 Q-20,-75 -15,-72 Q-10,-78 -5,-73 Q0,-80 5,-73 Q10,-78 15,-72 Q20,-75 25,-70"
stroke="#3a3a3a" stroke-width="4" fill="#3a3a3a" stroke-linecap="round"/>
<path d="M-28,-65 Q-25,-70 -20,-67" stroke="#3a3a3a" stroke-width="3" fill="none" stroke-linecap="round"/>
<path d="M28,-65 Q25,-70 20,-67" stroke="#3a3a3a" stroke-width="3" fill="none" stroke-linecap="round"/>
<!-- Левое ухо -->
<ellipse cx="-28" cy="-45" rx="6" ry="10" fill="url(#skinGradient)"/>
<!-- Правое ухо -->
<ellipse cx="28" cy="-45" rx="6" ry="10" fill="url(#skinGradient)"/>
<!-- Опухшие глаза -->
<ellipse cx="-10" cy="-50" rx="8" ry="5" fill="white"/>
<ellipse cx="10" cy="-50" rx="8" ry="5" fill="white"/>
<!-- Зрачки (косящие) -->
<g id="eyes">
<circle cx="-8" cy="-49" r="3" fill="#333">
<animate attributeName="cx" values="-8;-10;-8" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="12" cy="-49" r="3" fill="#333">
<animate attributeName="cx" values="12;10;12" dur="3s" repeatCount="indefinite"/>
</circle>
</g>
<!-- Красные глаза -->
<path d="M-18,-50 L-15,-51" stroke="red" stroke-width="1" opacity="0.7"/>
<path d="M-17,-48 L-14,-49" stroke="red" stroke-width="0.5" opacity="0.7"/>
<path d="M18,-50 L15,-51" stroke="red" stroke-width="1" opacity="0.7"/>
<path d="M17,-48 L14,-49" stroke="red" stroke-width="0.5" opacity="0.7"/>
<!-- Мешки под глазами -->
<ellipse cx="-10" cy="-44" rx="8" ry="3" fill="#8a6a4a" opacity="0.5"/>
<ellipse cx="10" cy="-44" rx="8" ry="3" fill="#8a6a4a" opacity="0.5"/>
<!-- Красный нос алкаша -->
<ellipse cx="0" cy="-38" rx="6" ry="5" fill="#d67070"/>
<circle cx="-2" cy="-39" r="1" fill="#e08080" opacity="0.8"/>
<!-- Небритость -->
<g opacity="0.6">
<circle cx="-15" cy="-30" r="1" fill="#555"/>
<circle cx="-12" cy="-28" r="0.8" fill="#555"/>
<circle cx="-8" cy="-30" r="0.8" fill="#555"/>
<circle cx="15" cy="-30" r="1" fill="#555"/>
<circle cx="12" cy="-28" r="0.8" fill="#555"/>
<circle cx="8" cy="-30" r="0.8" fill="#555"/>
<circle cx="0" cy="-28" r="0.8" fill="#555"/>
<circle cx="-5" cy="-26" r="0.8" fill="#555"/>
<circle cx="5" cy="-26" r="0.8" fill="#555"/>
</g>
<!-- Рот с сигаретой/бутылкой (анимированный) -->
<g id="mouth-group">
<!-- Основной рот -->
<path d="M-12,-20 Q0,-18 12,-20" stroke="#444" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Сигарета (появляется и исчезает) -->
<g id="cigarette">
<rect x="10" y="-22" width="25" height="3" fill="white" rx="1">
<animate attributeName="opacity" values="0;0;0;1;1;1;0;0;0" dur="8s" repeatCount="indefinite"/>
</rect>
<rect x="30" y="-22" width="5" height="3" fill="#ff6b35">
<animate attributeName="opacity" values="0;0;0;1;1;1;0;0;0" dur="8s" repeatCount="indefinite"/>
</rect>
<!-- Дым от сигареты -->
<g opacity="0.4">
<circle cx="35" cy="-22" r="2" fill="#999">
<animate attributeName="opacity" values="0;0;0;0.6;0.3;0;0;0;0" dur="8s" repeatCount="indefinite"/>
<animate attributeName="cy" values="-22;-22;-22;-28;-35;-40;-22;-22;-22" dur="8s" repeatCount="indefinite"/>
<animate attributeName="r" values="2;2;2;4;6;8;2;2;2" dur="8s" repeatCount="indefinite"/>
</circle>
</g>
</g>
</g>
<!-- Левая рука с бутылкой -->
<g id="left-arm">
<!-- Рука -->
<line x1="-45" y1="10" x2="-55" y2="-5" stroke="url(#skinGradient)" stroke-width="8" stroke-linecap="round">
<animate attributeName="x2" values="-55;-50;-55" dur="8s" repeatCount="indefinite"/>
<animate attributeName="y2" values="-5;-20;-5" dur="8s" repeatCount="indefinite"/>
</line>
<!-- Кисть -->
<circle cx="-55" cy="-5" r="6" fill="url(#skinGradient)">
<animate attributeName="cx" values="-55;-50;-55" dur="8s" repeatCount="indefinite"/>
<animate attributeName="cy" values="-5;-20;-5" dur="8s" repeatCount="indefinite"/>
</circle>
<!-- Бутылка -->
<g id="bottle">
<rect x="-62" y="-25" width="14" height="30" fill="url(#bottleGradient)" rx="2">
<animate attributeName="x" values="-62;-57;-62" dur="8s" repeatCount="indefinite"/>
<animate attributeName="y" values="-25;-40;-25" dur="8s" repeatCount="indefinite"/>
<animate attributeName="transform" values="rotate(0 -55 -10);rotate(-30 -50 -25);rotate(0 -55 -10)" dur="8s" repeatCount="indefinite"/>
</rect>
<rect x="-58" y="-30" width="6" height="8" fill="#2d5016">
<animate attributeName="x" values="-58;-53;-58" dur="8s" repeatCount="indefinite"/>
<animate attributeName="y" values="-30;-45;-30" dur="8s" repeatCount="indefinite"/>
</rect>
<!-- Этикетка -->
<rect x="-60" y="-15" width="10" height="8" fill="white" opacity="0.3">
<animate attributeName="x" values="-60;-55;-60" dur="8s" repeatCount="indefinite"/>
<animate attributeName="y" values="-15;-30;-15" dur="8s" repeatCount="indefinite"/>
</rect>
</g>
</g>
<!-- Правая рука (свободная, дрожащая) -->
<g id="right-arm">
<line x1="45" y1="10" x2="55" y2="20" stroke="url(#skinGradient)" stroke-width="8" stroke-linecap="round">
<animate attributeName="x2" values="55;57;55;53;55" dur="2s" repeatCount="indefinite"/>
</line>
<circle cx="55" cy="20" r="6" fill="url(#skinGradient)">
<animate attributeName="cx" values="55;57;55;53;55" dur="2s" repeatCount="indefinite"/>
</circle>
</g>
<!-- Ноги в рваных штанах -->
<rect x="-15" y="85" width="12" height="35" fill="#4a4a5a" rx="2"/>
<rect x="3" y="85" width="12" height="35" fill="#4a4a5a" rx="2"/>
<!-- Дырки в штанах -->
<ellipse cx="-9" cy="95" rx="3" ry="4" fill="#1a1a2e"/>
<ellipse cx="9" cy="105" rx="2" ry="3" fill="#1a1a2e"/>
<!-- Старые ботинки -->
<ellipse cx="-9" cy="125" rx="10" ry="6" fill="#2a2a2a"/>
<ellipse cx="9" cy="125" rx="10" ry="6" fill="#2a2a2a"/>
<!-- Дырки в ботинках -->
<circle cx="-6" cy="123" r="2" fill="#1a1a2e"/>
<circle cx="12" cy="123" r="1.5" fill="#1a1a2e"/>
<!-- Значок "B" на груди -->
<circle cx="0" cy="25" r="20" fill="url(#mainGradient)" opacity="0.8" stroke="white" stroke-width="1"/>
<text x="0" y="35"
font-family="Arial Black, sans-serif"
font-size="28"
font-weight="bold"
text-anchor="middle"
fill="white">
B
</text>
</g>
<!-- Упавшие бутылки вокруг -->
<g opacity="0.6">
<rect x="80" y="340" width="8" height="20" fill="#2d5016" rx="1" transform="rotate(75 84 350)"/>
<rect x="310" y="330" width="7" height="18" fill="#3a5a1a" rx="1" transform="rotate(-60 313 339)"/>
<rect x="150" y="350" width="6" height="16" fill="#2d5016" rx="1" transform="rotate(45 153 358)"/>
</g>
<!-- Окурки -->
<g opacity="0.7">
<rect x="120" y="360" width="12" height="2" fill="white" rx="1"/>
<rect x="130" y="360" width="2" height="2" fill="#ff6b35"/>
<rect x="250" y="355" width="10" height="2" fill="white" rx="1" transform="rotate(30 255 356)"/>
<rect x="258" y="355" width="2" height="2" fill="#ff6b35" transform="rotate(30 259 356)"/>
</g>
<!-- Текст логотипа -->
<text x="200" y="320"
font-family="Arial Black, sans-serif"
font-size="20"
font-weight="bold"
text-anchor="middle"
fill="url(#mainGradient)"
filter="url(#glow)">
Remnawave
</text>
<text x="200" y="345"
font-family="Arial Black, sans-serif"
font-size="18"
font-weight="bold"
text-anchor="middle"
fill="#d67070">
Bedolaga Bot
</text>
<!-- Подпись -->
<text x="200" y="365"
font-family="Arial, sans-serif"
font-size="11"
text-anchor="middle"
fill="#8892b0"
opacity="0.8">
Telegram VPN Manager
</text>
<!-- Мухи летающие вокруг (признак антисанитарии) -->
<g opacity="0.5">
<circle cx="150" cy="150" r="1" fill="#333">
<animateTransform attributeName="transform" type="rotate" values="0 200 180;360 200 180" dur="6s" repeatCount="indefinite"/>
</circle>
<circle cx="250" cy="150" r="1" fill="#333">
<animateTransform attributeName="transform" type="rotate" values="360 200 180;0 200 180" dur="5s" repeatCount="indefinite"/>
</circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

-73
View File
@@ -1,73 +0,0 @@
import os
from dataclasses import dataclass
from typing import List
@dataclass
class Config:
BOT_TOKEN: str
REMNAWAVE_URL: str
REMNAWAVE_TOKEN: str
REMNAWAVE_MODE: str
DATABASE_URL: str
ADMIN_IDS: List[int]
DEFAULT_LANGUAGE: str
SUPPORT_USERNAME: str
# Subscription URL settings
SUBSCRIPTION_BASE_URL: str
# Trial subscription settings
TRIAL_ENABLED: bool
TRIAL_DURATION_DAYS: int
TRIAL_TRAFFIC_GB: int
TRIAL_SQUAD_UUID: str
TRIAL_PRICE: float
# Monitor service settings
MONITOR_CHECK_INTERVAL: int
MONITOR_DAILY_CHECK_HOUR: int
MONITOR_WARNING_DAYS: int
def load_config() -> Config:
"""Load configuration from environment variables"""
# Parse admin IDs
admin_ids_str = os.getenv('ADMIN_IDS', '')
admin_ids = []
if admin_ids_str:
try:
admin_ids = [int(x.strip()) for x in admin_ids_str.split(',') if x.strip()]
except ValueError:
admin_ids = []
# Get subscription base URL -
subscription_base_url = os.getenv('SUBSCRIPTION_BASE_URL', '')
# Если SUBSCRIPTION_BASE_URL не установлен, используем значение по умолчанию
if not subscription_base_url:
subscription_base_url = 'https://sub.fring.tech'
return Config(
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
REMNAWAVE_URL=os.getenv('REMNAWAVE_URL', ''),
REMNAWAVE_TOKEN=os.getenv('REMNAWAVE_TOKEN', ''),
REMNAWAVE_MODE=os.getenv('REMNAWAVE_MODE', 'local'),
DATABASE_URL=os.getenv('DATABASE_URL', 'sqlite+aiosqlite:///bot.db'),
ADMIN_IDS=admin_ids,
DEFAULT_LANGUAGE=os.getenv('DEFAULT_LANGUAGE', 'ru'),
SUPPORT_USERNAME=os.getenv('SUPPORT_USERNAME', 'support'),
# Subscription URL
SUBSCRIPTION_BASE_URL=subscription_base_url,
# Trial subscription settings
TRIAL_ENABLED=os.getenv('TRIAL_ENABLED', 'true').lower() == 'true',
TRIAL_DURATION_DAYS=int(os.getenv('TRIAL_DURATION_DAYS', '3')),
TRIAL_TRAFFIC_GB=int(os.getenv('TRIAL_TRAFFIC_GB', '2')),
TRIAL_SQUAD_UUID=os.getenv('TRIAL_SQUAD_UUID', '19bd5bde-5eea-4368-809c-6ba1ffb93897'),
TRIAL_PRICE=float(os.getenv('TRIAL_PRICE', '0.0')),
# Monitor service settings
MONITOR_CHECK_INTERVAL=int(os.getenv('MONITOR_CHECK_INTERVAL', '3600')),
MONITOR_DAILY_CHECK_HOUR=int(os.getenv('MONITOR_DAILY_CHECK_HOUR', '10')),
MONITOR_WARNING_DAYS=int(os.getenv('MONITOR_WARNING_DAYS', '2'))
)
-735
View File
@@ -1,735 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import BigInteger, String, Float, DateTime, Boolean, Text, Integer, text
from datetime import datetime
from typing import Optional, List
import logging
logger = logging.getLogger(__name__)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = 'users'
id: Mapped[int] = mapped_column(primary_key=True)
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
username: Mapped[Optional[str]] = mapped_column(String(255))
first_name: Mapped[Optional[str]] = mapped_column(String(255))
last_name: Mapped[Optional[str]] = mapped_column(String(255))
language: Mapped[str] = mapped_column(String(10), default='ru')
balance: Mapped[float] = mapped_column(Float, default=0.0)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
remnawave_uuid: Mapped[Optional[str]] = mapped_column(String(255))
is_trial_used: Mapped[bool] = mapped_column(Boolean, default=False)
class Subscription(Base):
__tablename__ = 'subscriptions'
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[Optional[str]] = mapped_column(Text)
price: Mapped[float] = mapped_column(Float)
duration_days: Mapped[int] = mapped_column(Integer)
traffic_limit_gb: Mapped[int] = mapped_column(Integer, default=0) # 0 = unlimited
squad_uuid: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
is_trial: Mapped[bool] = mapped_column(Boolean, default=False)
is_imported: Mapped[bool] = mapped_column(Boolean, default=False)
class UserSubscription(Base):
__tablename__ = 'user_subscriptions'
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
subscription_id: Mapped[int] = mapped_column(Integer, index=True)
short_uuid: Mapped[str] = mapped_column(String(255)) # УБРАНО unique=True
expires_at: Mapped[datetime] = mapped_column(DateTime)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
traffic_limit_gb: Mapped[Optional[int]] = mapped_column(Integer) # Добавлено поле
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, onupdate=datetime.utcnow) # Добавлено поле
class Payment(Base):
__tablename__ = 'payments'
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
amount: Mapped[float] = mapped_column(Float)
payment_type: Mapped[str] = mapped_column(String(50)) # 'topup', 'subscription'
description: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(50), default='pending') # 'pending', 'completed', 'cancelled'
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Promocode(Base):
__tablename__ = 'promocodes'
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(255), unique=True, index=True)
discount_amount: Mapped[float] = mapped_column(Float)
discount_percent: Mapped[Optional[int]] = mapped_column(Integer)
usage_limit: Mapped[int] = mapped_column(Integer, default=1)
used_count: Mapped[int] = mapped_column(Integer, default=0)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class PromocodeUsage(Base):
__tablename__ = 'promocode_usage'
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
promocode_id: Mapped[int] = mapped_column(Integer, index=True)
used_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Database:
def __init__(self, database_url: str):
self.engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_recycle=300
)
self.session_factory = async_sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False
)
async def init_db(self):
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Выполняем миграции
await self.migrate_user_subscriptions()
await self.migrate_subscription_imported_field()
async def close(self):
await self.engine.dispose()
# User methods
async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[User]:
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(User).where(User.telegram_id == telegram_id)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error getting user by telegram_id {telegram_id}: {e}")
return None
async def create_user(self, telegram_id: int, username: str = None,
first_name: str = None, last_name: str = None,
language: str = 'ru', is_admin: bool = False) -> User:
async with self.session_factory() as session:
try:
user = User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language=language,
is_admin=is_admin
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
except Exception as e:
logger.error(f"Error creating user {telegram_id}: {e}")
await session.rollback()
raise
async def update_user(self, user: User) -> User:
async with self.session_factory() as session:
try:
await session.merge(user)
await session.commit()
return user
except Exception as e:
logger.error(f"Error updating user {user.telegram_id}: {e}")
await session.rollback()
raise
async def add_balance(self, user_id: int, amount: float) -> bool:
async with self.session_factory() as session:
try:
from sqlalchemy import select, update
result = await session.execute(
update(User)
.where(User.telegram_id == user_id)
.values(balance=User.balance + amount)
)
await session.commit()
return result.rowcount > 0
except Exception as e:
logger.error(f"Error adding balance to user {user_id}: {e}")
await session.rollback()
return False
# Subscription methods
async def get_all_subscriptions(self, include_inactive: bool = False, exclude_trial: bool = True, exclude_imported: bool = True) -> List[Subscription]:
async with self.session_factory() as session:
try:
from sqlalchemy import select
query = select(Subscription)
if not include_inactive:
query = query.where(Subscription.is_active == True)
if exclude_trial:
query = query.where(Subscription.is_trial == False)
if exclude_imported:
query = query.where(Subscription.is_imported == False) # Исключаем импортированные
result = await session.execute(query)
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting subscriptions: {e}")
return []
async def get_all_subscriptions_admin(self) -> List[Subscription]:
"""Get all subscriptions including imported ones (for admin purposes)"""
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(select(Subscription))
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting admin subscriptions: {e}")
return []
async def migrate_subscription_imported_field(self):
"""Add is_imported field to subscriptions table"""
try:
async with self.engine.begin() as conn:
try:
await conn.execute(text("""
ALTER TABLE subscriptions
ADD COLUMN IF NOT EXISTS is_imported BOOLEAN DEFAULT FALSE
"""))
logger.info("Successfully added is_imported field to subscriptions table")
except Exception as e:
logger.info(f"Migration may have already been applied: {e}")
except Exception as e:
logger.error(f"Error during subscription migration: {e}")
async def get_subscription_by_id(self, subscription_id: int) -> Optional[Subscription]:
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(Subscription).where(Subscription.id == subscription_id)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error getting subscription {subscription_id}: {e}")
return None
async def create_subscription(self, name: str, description: str, price: float,
duration_days: int, traffic_limit_gb: int,
squad_uuid: str, is_imported: bool = False) -> Subscription:
async with self.session_factory() as session:
try:
subscription = Subscription(
name=name,
description=description,
price=price,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
squad_uuid=squad_uuid,
is_imported=is_imported # Добавляем поддержку is_imported
)
session.add(subscription)
await session.commit()
await session.refresh(subscription)
return subscription
except Exception as e:
logger.error(f"Error creating subscription: {e}")
await session.rollback()
raise
async def update_subscription(self, subscription: Subscription) -> Subscription:
async with self.session_factory() as session:
try:
await session.merge(subscription)
await session.commit()
return subscription
except Exception as e:
logger.error(f"Error updating subscription {subscription.id}: {e}")
await session.rollback()
raise
async def delete_subscription(self, subscription_id: int) -> bool:
async with self.session_factory() as session:
try:
from sqlalchemy import delete
result = await session.execute(
delete(Subscription).where(Subscription.id == subscription_id)
)
await session.commit()
return result.rowcount > 0
except Exception as e:
logger.error(f"Error deleting subscription {subscription_id}: {e}")
await session.rollback()
return False
async def get_user_subscriptions(self, user_id: int) -> List[UserSubscription]:
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(UserSubscription).where(UserSubscription.user_id == user_id)
)
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting user subscriptions for {user_id}: {e}")
return []
async def create_user_subscription(self, user_id: int, subscription_id: int,
short_uuid: str, expires_at: datetime,
is_active: bool = True, traffic_limit_gb: int = None) -> Optional[UserSubscription]:
"""Create user subscription with proper error handling"""
async with self.session_factory() as session:
try:
# Проверяем что подписка не существует
from sqlalchemy import select
existing = await session.execute(
select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.short_uuid == short_uuid
)
)
existing_sub = existing.scalar_one_or_none()
if existing_sub:
logger.warning(f"Subscription with short_uuid {short_uuid} already exists for user {user_id}")
return existing_sub
# Создаем новую подписку
new_subscription = UserSubscription(
user_id=user_id,
subscription_id=subscription_id,
short_uuid=short_uuid,
expires_at=expires_at,
is_active=is_active
)
session.add(new_subscription)
await session.commit()
await session.refresh(new_subscription)
return new_subscription
except Exception as e:
logger.error(f"Error creating user subscription: {e}")
await session.rollback()
return None
# Payment methods
async def create_payment(self, user_id: int, amount: float, payment_type: str,
description: str, status: str = 'pending') -> Payment:
async with self.session_factory() as session:
try:
payment = Payment(
user_id=user_id,
amount=amount,
payment_type=payment_type,
description=description,
status=status
)
session.add(payment)
await session.commit()
await session.refresh(payment)
return payment
except Exception as e:
logger.error(f"Error creating payment: {e}")
await session.rollback()
raise
async def get_payment_by_id(self, payment_id: int) -> Optional[Payment]:
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(Payment).where(Payment.id == payment_id)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error getting payment {payment_id}: {e}")
return None
async def update_payment(self, payment: Payment) -> Payment:
async with self.session_factory() as session:
try:
await session.merge(payment)
await session.commit()
return payment
except Exception as e:
logger.error(f"Error updating payment {payment.id}: {e}")
await session.rollback()
raise
async def get_user_payments(self, user_id: int) -> List[Payment]:
async with self.session_factory() as session:
try:
from sqlalchemy import select, desc
result = await session.execute(
select(Payment)
.where(Payment.user_id == user_id)
.order_by(desc(Payment.created_at))
)
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting user payments for {user_id}: {e}")
return []
# Promocode methods
async def get_promocode_by_code(self, code: str) -> Optional[Promocode]:
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(Promocode).where(Promocode.code == code)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error getting promocode {code}: {e}")
return None
async def create_promocode(self, code: str, discount_amount: float = 0,
discount_percent: int = None, usage_limit: int = 1,
expires_at: datetime = None) -> Promocode:
async with self.session_factory() as session:
try:
promocode = Promocode(
code=code,
discount_amount=discount_amount,
discount_percent=discount_percent,
usage_limit=usage_limit,
expires_at=expires_at
)
session.add(promocode)
await session.commit()
await session.refresh(promocode)
return promocode
except Exception as e:
logger.error(f"Error creating promocode: {e}")
await session.rollback()
raise
async def use_promocode(self, user_id: int, promocode: Promocode) -> bool:
async with self.session_factory() as session:
try:
# Check if already used
from sqlalchemy import select
existing = await session.execute(
select(PromocodeUsage).where(
PromocodeUsage.user_id == user_id,
PromocodeUsage.promocode_id == promocode.id
)
)
if existing.scalar_one_or_none():
return False
# Create usage record
usage = PromocodeUsage(user_id=user_id, promocode_id=promocode.id)
session.add(usage)
# Update promocode used count
promocode.used_count += 1
await session.merge(promocode)
await session.commit()
return True
except Exception as e:
logger.error(f"Error using promocode: {e}")
await session.rollback()
return False
async def get_all_promocodes(self) -> List[Promocode]:
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(select(Promocode))
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting promocodes: {e}")
return []
# Admin methods
async def get_all_users(self) -> List[User]:
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(select(User))
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting all users: {e}")
return []
async def get_stats(self) -> dict:
async with self.session_factory() as session:
try:
from sqlalchemy import select, func
# Total users
total_users = await session.execute(
select(func.count(User.id))
)
total_users = total_users.scalar()
# Total subscriptions (excluding trial)
total_subs_non_trial = await session.execute(
select(func.count(UserSubscription.id))
.join(Subscription, UserSubscription.subscription_id == Subscription.id)
.where(Subscription.is_trial == False)
)
total_subs_non_trial = total_subs_non_trial.scalar()
# Total payments (excluding trial payments)
total_payments = await session.execute(
select(func.sum(Payment.amount)).where(
Payment.status == 'completed',
Payment.payment_type != 'trial' # Исключаем тестовые платежи
)
)
total_payments = total_payments.scalar() or 0
return {
'total_users': total_users,
'total_subscriptions_non_trial': total_subs_non_trial,
'total_revenue': total_payments
}
except Exception as e:
logger.error(f"Error getting stats: {e}")
return {
'total_users': 0,
'total_subscriptions_non_trial': 0,
'total_revenue': 0
}
async def get_trial_subscriptions(self) -> List[Subscription]:
"""Get only trial subscriptions"""
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(Subscription).where(Subscription.is_trial == True)
)
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting trial subscriptions: {e}")
return []
async def get_user_subscription_by_short_uuid(self, user_id: int, short_uuid: str) -> Optional[UserSubscription]:
"""Get user subscription by short_uuid"""
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.short_uuid == short_uuid
)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error getting user subscription by short_uuid: {e}")
return None
async def update_user_subscription(self, user_subscription: UserSubscription) -> bool:
"""Update user subscription"""
async with self.session_factory() as session:
try:
# Устанавливаем время обновления
user_subscription.updated_at = datetime.utcnow()
# Обновляем подписку
await session.merge(user_subscription)
await session.commit()
return True
except Exception as e:
logger.error(f"Error updating user subscription: {e}")
await session.rollback()
return False
async def migrate_user_subscriptions(self):
"""Migrate user_subscriptions table to add missing columns"""
try:
async with self.engine.begin() as conn:
# Проверяем существование столбцов и добавляем их если нет
try:
await conn.execute(text("""
ALTER TABLE user_subscriptions
ADD COLUMN IF NOT EXISTS traffic_limit_gb INTEGER,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP
"""))
logger.info("Successfully migrated user_subscriptions table")
except Exception as e:
logger.info(f"Migration may have already been applied or error occurred: {e}")
except Exception as e:
logger.error(f"Error during migration: {e}")
async def get_expiring_subscriptions(self, user_id: int, days_threshold: int = 3) -> List[UserSubscription]:
async with self.session_factory() as session:
try:
from sqlalchemy import select
from datetime import datetime, timedelta
threshold_date = datetime.utcnow() + timedelta(days=days_threshold)
result = await session.execute(
select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True,
UserSubscription.expires_at <= threshold_date,
UserSubscription.expires_at > datetime.utcnow()
)
)
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting expiring subscriptions for {user_id}: {e}")
return []
async def has_used_trial(self, user_id: int) -> bool:
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(User.is_trial_used).where(User.telegram_id == user_id)
)
is_trial_used = result.scalar_one_or_none()
return is_trial_used or False
except Exception as e:
logger.error(f"Error checking trial usage for user {user_id}: {e}")
return False
async def mark_trial_used(self, user_id: int) -> bool:
async with self.session_factory() as session:
try:
from sqlalchemy import update
result = await session.execute(
update(User)
.where(User.telegram_id == user_id)
.values(is_trial_used=True)
)
await session.commit()
return result.rowcount > 0
except Exception as e:
logger.error(f"Error marking trial used for user {user_id}: {e}")
await session.rollback()
return False
async def get_all_payments_paginated(self, offset: int = 0, limit: int = 10) -> tuple[List[Payment], int]:
"""Get all payments with pagination"""
async with self.session_factory() as session:
try:
from sqlalchemy import select, desc, func
# Получаем общее количество записей
count_result = await session.execute(
select(func.count(Payment.id))
)
total_count = count_result.scalar()
# Получаем платежи с пагинацией
result = await session.execute(
select(Payment)
.order_by(desc(Payment.created_at))
.offset(offset)
.limit(limit)
)
payments = list(result.scalars().all())
return payments, total_count
except Exception as e:
logger.error(f"Error getting paginated payments: {e}")
return [], 0
async def get_payments_by_type_paginated(self, payment_type: str, offset: int = 0, limit: int = 10) -> tuple[List[Payment], int]:
"""Get payments by type with pagination"""
async with self.session_factory() as session:
try:
from sqlalchemy import select, desc, func
# Получаем общее количество записей
count_result = await session.execute(
select(func.count(Payment.id)).where(Payment.payment_type == payment_type)
)
total_count = count_result.scalar()
# Получаем платежи с пагинацией
result = await session.execute(
select(Payment)
.where(Payment.payment_type == payment_type)
.order_by(desc(Payment.created_at))
.offset(offset)
.limit(limit)
)
payments = list(result.scalars().all())
return payments, total_count
except Exception as e:
logger.error(f"Error getting paginated payments by type: {e}")
return [], 0
async def get_payments_by_status_paginated(self, status: str, offset: int = 0, limit: int = 10) -> tuple[List[Payment], int]:
"""Get payments by status with pagination"""
async with self.session_factory() as session:
try:
from sqlalchemy import select, desc, func
# Получаем общее количество записей
count_result = await session.execute(
select(func.count(Payment.id)).where(Payment.status == status)
)
total_count = count_result.scalar()
# Получаем платежи с пагинацией
result = await session.execute(
select(Payment)
.where(Payment.status == status)
.order_by(desc(Payment.created_at))
.offset(offset)
.limit(limit)
)
payments = list(result.scalars().all())
return payments, total_count
except Exception as e:
logger.error(f"Error getting paginated payments by status: {e}")
return [], 0
async def get_user_subscriptions_by_plan_id(self, plan_id: int) -> List[UserSubscription]:
"""Get all user subscriptions for a specific plan"""
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(UserSubscription).where(UserSubscription.subscription_id == plan_id)
)
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting user subscriptions for plan {plan_id}: {e}")
return []
async def delete_user_subscription(self, user_subscription_id: int) -> bool:
"""Delete user subscription by ID"""
async with self.session_factory() as session:
try:
from sqlalchemy import delete
result = await session.execute(
delete(UserSubscription).where(UserSubscription.id == user_subscription_id)
)
await session.commit()
return result.rowcount > 0
except Exception as e:
logger.error(f"Error deleting user subscription {user_subscription_id}: {e}")
await session.rollback()
return False
+39 -64
View File
@@ -1,98 +1,73 @@
services:
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: remnawave_bot_db
restart: unless-stopped
environment:
POSTGRES_DB: remnawave_bot
POSTGRES_USER: remnawave_user
POSTGRES_PASSWORD: secure_password_123
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
ports:
- "5432:5432"
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U remnawave_user -d remnawave_bot"]
interval: 15s
timeout: 10s
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
# RemnaWave Bot
redis:
image: redis:7-alpine
container_name: remnawave_bot_redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
networks:
- bot_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
bot:
build:
context: .
dockerfile: Dockerfile
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
container_name: remnawave_bot
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- .env
environment:
# Override database URL to use PostgreSQL container
DATABASE_URL: postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
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
volumes:
- ./logs:/app/logs
- ./data:/app/data
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c 'print(\"Bot is running\")'"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# Redis (optional, for caching and session storage)
redis:
image: redis:7-alpine
container_name: remnawave_bot_redis
restart: unless-stopped
command: redis-server --appendonly yes --requirepass redis_password_123
volumes:
- redis_data:/data
- ./logs:/app/logs:rw
- ./data:/app/data:rw
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "6379:6379"
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
networks:
- bot_network
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 3s
retries: 5
# Nginx (optional, for serving static files or reverse proxy)
nginx:
image: nginx:alpine
container_name: remnawave_bot_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
- ./static:/usr/share/nginx/html:ro
networks:
- bot_network
depends_on:
- bot
profiles:
- with-nginx
user: "1000:1000"
command: >
bash -c "
mkdir -p /app/logs /app/data &&
python main.py
"
volumes:
postgres_data:
driver: local
redis_data:
driver: local
networks:
bot_network:
-1240
View File
File diff suppressed because it is too large Load Diff
-507
View File
@@ -1,507 +0,0 @@
from database import Subscription
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from typing import List, Optional, Dict
from translations import t
def language_keyboard() -> InlineKeyboardMarkup:
"""Language selection keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🇷🇺 Русский", callback_data="lang_ru"),
InlineKeyboardButton(text="🇺🇸 English", callback_data="lang_en")
]
])
return keyboard
def main_menu_keyboard(lang: str = 'ru', is_admin: bool = False, show_trial: bool = False) -> InlineKeyboardMarkup:
"""Beautiful main menu keyboard with emojis and better layout"""
buttons = [
# Первый ряд - основные функции
[
InlineKeyboardButton(text="💰 " + t('balance', lang), callback_data="balance"),
InlineKeyboardButton(text="📋 " + t('my_subscriptions', lang), callback_data="my_subscriptions")
],
# Второй ряд - покупка подписки (выделена отдельно как главная функция)
[InlineKeyboardButton(text="🛒 " + t('buy_subscription', lang), callback_data="buy_subscription")],
]
# Добавляем тестовую подписку если доступна
if show_trial:
buttons.insert(1, [InlineKeyboardButton(text="🆓 Тестовая подписка", callback_data="trial_subscription")])
# Добавляем остальные кнопки
buttons.extend([
# Дополнительные функции
[
InlineKeyboardButton(text="🎁 " + t('promocode', lang), callback_data="promocode"),
InlineKeyboardButton(text="💬 " + t('support', lang), callback_data="support")
],
# Последний ряд - настройки
[InlineKeyboardButton(text="🌐 " + t('change_language', lang), callback_data="change_language")]
])
# Добавляем админ панель если пользователь админ
if is_admin:
buttons.append([InlineKeyboardButton(text="⚙️ " + t('admin_panel', lang), callback_data="admin_panel")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
# Остальные функции остаются прежними...
def balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful balance menu keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 " + t('topup_balance', lang), callback_data="topup_balance")],
[InlineKeyboardButton(text="📊 " + t('payment_history', lang), callback_data="payment_history")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
])
return keyboard
def topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful top up balance keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 " + t('topup_card', lang), callback_data="topup_card")],
[InlineKeyboardButton(text="👨‍💼 " + t('topup_support', lang), callback_data="topup_support")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
])
return keyboard
def subscriptions_keyboard(subscriptions: List[dict], lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful available subscriptions keyboard"""
buttons = []
# Группируем подписки по две в ряд для компактности
for i in range(0, len(subscriptions), 2):
row = []
for j in range(2):
if i + j < len(subscriptions):
sub = subscriptions[i + j]
price_text = f"{sub['price']:.0f}"
# Используем разные эмодзи для разных ценовых категорий
if sub['price'] <= 100:
emoji = "🥉"
elif sub['price'] <= 300:
emoji = "🥈"
else:
emoji = "🥇"
row.append(InlineKeyboardButton(
text=f"{emoji} {sub['name']} - {price_text}",
callback_data=f"buy_sub_{sub['id']}"
))
buttons.append(row)
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def subscription_detail_keyboard(subscription_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful subscription detail keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="💎 " + t('buy_subscription_btn', lang, price=0),
callback_data=f"confirm_buy_{subscription_id}"
)],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="buy_subscription")]
])
return keyboard
def user_subscriptions_keyboard(user_subscriptions: List[dict], lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful user's subscriptions keyboard"""
buttons = []
for sub in user_subscriptions:
# Добавляем эмодзи статуса для каждой подписки
buttons.append([InlineKeyboardButton(
text=f"📱 {sub['name']}",
callback_data=f"view_sub_{sub['id']}"
)])
if not user_subscriptions:
# Если нет подписок, показываем кнопку покупки
buttons.append([InlineKeyboardButton(text="🛒 " + t('buy_subscription', lang), callback_data="buy_subscription")])
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def user_subscription_detail_keyboard(subscription_id: int, lang: str = 'ru', show_extend: bool = False) -> InlineKeyboardMarkup:
"""Beautiful user's subscription detail keyboard with connection and optional extend button"""
buttons = []
# Add extend button if subscription is expiring soon
if show_extend:
buttons.append([InlineKeyboardButton(text="" + t('extend_subscription', lang), callback_data=f"extend_sub_{subscription_id}")])
# Connection button (главная кнопка)
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
# Back button
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="my_subscriptions")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def extend_subscription_keyboard(subscription_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful extend subscription confirmation keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да, продлить", callback_data=f"confirm_extend_{subscription_id}"),
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_sub_{subscription_id}")
]
])
return keyboard
def back_keyboard(callback_data: str, lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful back button keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data=callback_data)]
])
return keyboard
def cancel_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful cancel button keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="" + t('cancel', lang), callback_data="main_menu")]
])
return keyboard
# Admin keyboards
def admin_menu_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful admin menu keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
# Первый ряд - управление контентом
[
InlineKeyboardButton(text="📦 " + t('manage_subscriptions', lang), callback_data="admin_subscriptions"),
InlineKeyboardButton(text="👥 " + t('manage_users', lang), callback_data="admin_users")
],
# Второй ряд - финансы
[
InlineKeyboardButton(text="💰 " + t('manage_balance', lang), callback_data="admin_balance"),
InlineKeyboardButton(text="🎁 " + t('manage_promocodes', lang), callback_data="admin_promocodes")
],
# Третий ряд - коммуникации и система
[
InlineKeyboardButton(text="📨 " + t('send_message', lang), callback_data="admin_messages"),
InlineKeyboardButton(text="🖥 Система RemnaWave", callback_data="admin_system") # НОВОЕ!
],
# Четвертый ряд - мониторинг и статистика
[
InlineKeyboardButton(text="🔍 Мониторинг подписок", callback_data="admin_monitor"),
InlineKeyboardButton(text="📊 " + t('statistics', lang), callback_data="admin_stats")
],
# Назад
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
])
return keyboard
def admin_subscriptions_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful admin subscriptions management keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=" " + t('create_subscription', lang), callback_data="create_subscription")],
[InlineKeyboardButton(text="📋 Список подписок", callback_data="list_admin_subscriptions")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
])
return keyboard
def admin_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful admin users management keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="👥 Список пользователей", callback_data="list_users")],
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
])
return keyboard
def admin_balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful admin balance management keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💸 Пополнить баланс пользователю", callback_data="admin_add_balance")],
[InlineKeyboardButton(text="📊 История платежей", callback_data="admin_payment_history")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
])
return keyboard
def admin_promocodes_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful admin promocodes management keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=" " + t('create_promocode', lang), callback_data="create_promocode")],
[InlineKeyboardButton(text="📋 Список промокодов", callback_data="list_promocodes")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
])
return keyboard
def confirmation_keyboard(confirm_callback: str, cancel_callback: str, lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful confirmation keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да", callback_data=confirm_callback),
InlineKeyboardButton(text="❌ Нет", callback_data=cancel_callback)
]
])
return keyboard
def pagination_keyboard(page: int, total_pages: int, prefix: str, lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful pagination keyboard"""
buttons = []
if total_pages > 1:
nav_buttons = []
if page > 1:
nav_buttons.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"{prefix}_page_{page-1}"))
nav_buttons.append(InlineKeyboardButton(text=f"📄 {page}/{total_pages}", callback_data="noop"))
if page < total_pages:
nav_buttons.append(InlineKeyboardButton(text="Вперед ➡️", callback_data=f"{prefix}_page_{page+1}"))
buttons.append(nav_buttons)
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def admin_subscriptions_list_keyboard(subs: List[Subscription], lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful keyboard for admin subscriptions list with enhanced controls"""
buttons = []
for sub in subs:
status_emoji = "🟢" if sub.is_active else "🔴"
price = f"{sub.price:.0f}"
# Основная кнопка с информацией о подписке
buttons.append([
InlineKeyboardButton(
text=f"{status_emoji} {sub.name}{price}",
callback_data=f"list_sub_{sub.id}"
)
])
# Кнопки управления для каждой подписки в одну строку
control_buttons = [
InlineKeyboardButton(text="✏️", callback_data=f"edit_sub_{sub.id}"),
InlineKeyboardButton(
text="🟢" if not sub.is_active else "🔴",
callback_data=f"toggle_sub_{sub.id}"
),
InlineKeyboardButton(text="🗑", callback_data=f"delete_sub_{sub.id}")
]
buttons.append(control_buttons)
# Кнопка создания новой подписки
buttons.append([InlineKeyboardButton(text=" Создать подписку", callback_data="create_subscription")])
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_subscriptions")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def admin_payment_keyboard(payment_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful keyboard for admin payment approval"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✅ Одобрить платеж", callback_data=f"approve_payment_{payment_id}"),
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"reject_payment_{payment_id}")
]
])
return keyboard
def admin_messages_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful admin messages management keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="👤 " + t('send_to_user', lang), callback_data="admin_send_to_user")],
[InlineKeyboardButton(text="📢 " + t('send_to_all', lang), callback_data="admin_send_to_all")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
])
return keyboard
def quick_topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Quick topup amounts keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="💳 100₽", callback_data="quick_topup_100"),
InlineKeyboardButton(text="💳 300₽", callback_data="quick_topup_300")
],
[
InlineKeyboardButton(text="💳 500₽", callback_data="quick_topup_500"),
InlineKeyboardButton(text="💳 1000₽", callback_data="quick_topup_1000")
],
[InlineKeyboardButton(text="💰 Другая сумма", callback_data="topup_support")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
])
return keyboard
def connection_keyboard(subscription_url: str, lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful connection keyboard with web app"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", url=subscription_url)],
[InlineKeyboardButton(text="📱 Инструкция", callback_data="connection_guide")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="my_subscriptions")]
])
return keyboard
def trial_subscription_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Trial subscription confirmation keyboard"""
buttons = [
[InlineKeyboardButton(text="✅ Получить тестовую подписку", callback_data="confirm_trial")],
[InlineKeyboardButton(text=t('back', lang), callback_data="main_menu")]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
def admin_monitor_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful admin monitor management keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📊 Статус сервиса", callback_data="monitor_status")],
[InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="monitor_force_check")],
[InlineKeyboardButton(text="⚰️ Деактивировать истекшие", callback_data="monitor_deactivate_expired")],
[InlineKeyboardButton(text="👤 Тест для пользователя", callback_data="monitor_test_user")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
])
return keyboard
def admin_system_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful admin system management keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📊 Системная статистика", callback_data="system_stats")],
[InlineKeyboardButton(text="🖥 Управление нодами", callback_data="nodes_management")],
[InlineKeyboardButton(text="👥 Пользователи системы", callback_data="system_users")],
[InlineKeyboardButton(text="🔄 Синхронизация с RemnaWave", callback_data="sync_remnawave")],
[InlineKeyboardButton(text="🔍 Отладка API", callback_data="debug_api_comprehensive")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
])
return keyboard
def system_stats_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""System statistics keyboard with refresh"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить статистику", callback_data="refresh_system_stats")],
[InlineKeyboardButton(text="🖥 Ноды", callback_data="nodes_management")],
[InlineKeyboardButton(text="👥 Системные пользователи", callback_data="system_users")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")]
])
return keyboard
def nodes_management_keyboard(nodes: List[Dict], lang: str = 'ru', timestamp: int = None) -> InlineKeyboardMarkup:
"""Improved nodes management keyboard"""
buttons = []
if nodes:
# Statistics row
online_count = len([n for n in nodes if n.get('status') == 'online'])
total_count = len(nodes)
buttons.append([
InlineKeyboardButton(
text=f"📊 Ноды: {online_count}/{total_count} онлайн",
callback_data="noop"
)
])
# Show first 5 nodes with improved display
for i, node in enumerate(nodes[:5]):
status = node.get('status', 'unknown')
# Status emoji based on actual status
if status == 'online':
status_emoji = "🟢"
elif status == 'disabled':
status_emoji = ""
elif status == 'disconnected':
status_emoji = "🔴"
elif status == 'xray_stopped':
status_emoji = "🟡"
else:
status_emoji = ""
node_name = node.get('name', f'Node-{i+1}')
node_id = node.get('id', node.get('uuid'))
# Truncate long names
if len(node_name) > 20:
display_name = node_name[:17] + "..."
else:
display_name = node_name
# CPU/Memory usage if available
usage_info = ""
if node.get('cpuUsage'):
usage_info += f" CPU:{node['cpuUsage']:.0f}%"
if node.get('memUsage'):
usage_info += f" MEM:{node['memUsage']:.0f}%"
buttons.append([
InlineKeyboardButton(
text=f"{status_emoji} {display_name}{usage_info}",
callback_data=f"node_details_{node_id}"
),
InlineKeyboardButton(
text="🔄",
callback_data=f"restart_node_{node_id}"
),
InlineKeyboardButton(
text="⚙️",
callback_data=f"node_settings_{node_id}"
)
])
if len(nodes) > 5:
buttons.append([
InlineKeyboardButton(
text=f"... и еще {len(nodes) - 5} нод",
callback_data="show_all_nodes"
)
])
else:
buttons.append([
InlineKeyboardButton(
text="❌ Ноды не найдены",
callback_data="noop"
)
])
# Action buttons
buttons.append([
InlineKeyboardButton(text="🔄 Перезагрузить все", callback_data="restart_all_nodes"),
InlineKeyboardButton(text="📊 Статистика", callback_data="nodes_statistics")
])
# Refresh button
refresh_callback = f"refresh_nodes_stats_{timestamp}" if timestamp else "refresh_nodes_stats"
buttons.append([
InlineKeyboardButton(text="🔄 Обновить", callback_data=refresh_callback)
])
# Back button
buttons.append([
InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def system_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""System users management keyboard - ИСПРАВЛЕНО"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📊 Статистика пользователей", callback_data="users_statistics")],
[InlineKeyboardButton(text="👥 Список всех пользователей", callback_data="list_all_system_users")],
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user_uuid")],
[InlineKeyboardButton(text="🔍 Отладка API пользователей", callback_data="debug_users_api")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_system")]
])
return keyboard
def bulk_operations_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Bulk operations keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="bulk_reset_traffic")],
[InlineKeyboardButton(text="❌ Отключить пользователей", callback_data="bulk_disable_users")],
[InlineKeyboardButton(text="✅ Включить пользователей", callback_data="bulk_enable_users")],
[InlineKeyboardButton(text="🗑 Удалить пользователей", callback_data="bulk_delete_users")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="system_users")]
])
return keyboard
def confirm_restart_keyboard(node_id: str = None, lang: str = 'ru') -> InlineKeyboardMarkup:
"""Confirmation keyboard for node restart"""
action = f"confirm_restart_node_{node_id}" if node_id else "confirm_restart_all_nodes"
back_action = f"node_details_{node_id}" if node_id else "nodes_management"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да, перезагрузить", callback_data=action),
InlineKeyboardButton(text="❌ Отмена", callback_data=back_action)
]
])
return keyboard
+194 -231
View File
@@ -2,254 +2,217 @@ import asyncio
import logging
import sys
import os
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
import signal
from pathlib import Path
# Import our modules
from config import load_config
from database import Database
from remnawave_api import RemnaWaveAPI
from subscription_monitor import create_subscription_monitor
from middlewares import DatabaseMiddleware, UserMiddleware, LoggingMiddleware, ThrottlingMiddleware, WorkflowDataMiddleware, BotMiddleware
from handlers import router
from admin_handlers import admin_router
sys.path.append(str(Path(__file__).parent))
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('bot.log'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
from app.bot import setup_bot
from app.config import settings
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.external.webhook_server import WebhookServer
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.database.universal_migration import run_universal_migration
class BotApplication:
"""Main bot application class"""
class GracefulExit:
def __init__(self):
self.config = None
self.db = None
self.api = None
self.bot = None
self.dp = None
self.monitor_service = None
self.exit = False
async def initialize(self):
"""Initialize all components"""
# Load configuration
self.config = load_config()
# Validate required environment variables
if not self.config.BOT_TOKEN:
logger.error("BOT_TOKEN is required")
raise ValueError("BOT_TOKEN is required")
if not self.config.REMNAWAVE_URL or not self.config.REMNAWAVE_TOKEN:
logger.error("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
raise ValueError("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
logger.info("Starting RemnaWave Bot...")
logger.info(f"RemnaWave URL: {self.config.REMNAWAVE_URL}")
logger.info(f"Admin IDs: {self.config.ADMIN_IDS}")
# Initialize database
self.db = Database(self.config.DATABASE_URL)
await self._init_database()
# Initialize RemnaWave API
self.api = RemnaWaveAPI(
self.config.REMNAWAVE_URL,
self.config.REMNAWAVE_TOKEN,
self.config.SUBSCRIPTION_BASE_URL
)
logger.info("RemnaWave API initialized")
# Test API connection (optional - don't fail if it doesn't work)
await self._test_api_connection()
# Initialize bot and dispatcher
self.bot = Bot(
token=self.config.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
# Test bot token
await self._test_bot_token()
# Initialize dispatcher
self._setup_dispatcher()
# Initialize subscription monitor service
await self._init_monitor_service()
async def _init_database(self):
"""Initialize database with retry logic"""
max_retries = 3
for attempt in range(max_retries):
try:
await self.db.init_db()
logger.info("Database initialized successfully")
break
except Exception as e:
logger.error(f"Database initialization attempt {attempt + 1} failed: {e}")
if attempt == max_retries - 1:
logger.error("Failed to initialize database after all retries")
raise
await asyncio.sleep(2) # Wait before retry
async def _test_api_connection(self):
"""Test API connection"""
try:
system_stats = await self.api.get_system_stats()
if system_stats:
logger.info("RemnaWave API connection successful")
else:
logger.warning("RemnaWave API connection test failed - continuing anyway")
except Exception as e:
logger.warning(f"RemnaWave API connection error: {e} - continuing anyway")
async def _test_bot_token(self):
"""Test bot token before starting"""
try:
bot_info = await self.bot.get_me()
logger.info(f"Bot started: @{bot_info.username} ({bot_info.first_name})")
except Exception as e:
logger.error(f"Invalid bot token or network error: {e}")
raise
def _setup_dispatcher(self):
"""Setup dispatcher with middlewares and routers"""
storage = MemoryStorage()
self.dp = Dispatcher(storage=storage)
# Store config, api, db, and monitor_service in dispatcher workflow_data for access in handlers
self.dp.workflow_data.update({
"config": self.config,
"api": self.api,
"db": self.db,
"monitor_service": None # Will be updated after monitor service is created
})
# Setup middlewares in correct order
self.dp.message.middleware(LoggingMiddleware())
self.dp.callback_query.middleware(LoggingMiddleware())
self.dp.message.middleware(ThrottlingMiddleware(rate_limit=0.5))
self.dp.callback_query.middleware(ThrottlingMiddleware(rate_limit=0.3))
self.dp.message.middleware(WorkflowDataMiddleware())
self.dp.callback_query.middleware(WorkflowDataMiddleware())
self.dp.message.middleware(BotMiddleware(self.bot))
self.dp.callback_query.middleware(BotMiddleware(self.bot))
self.dp.message.middleware(DatabaseMiddleware(self.db))
self.dp.callback_query.middleware(DatabaseMiddleware(self.db))
self.dp.message.middleware(UserMiddleware(self.db, self.config))
self.dp.callback_query.middleware(UserMiddleware(self.db, self.config))
# Register routers
self.dp.include_router(router)
self.dp.include_router(admin_router)
async def _init_monitor_service(self):
"""Initialize subscription monitor service"""
try:
self.monitor_service = await create_subscription_monitor(
self.bot, self.db, self.config, self.api
)
# Update workflow_data with monitor service
self.dp.workflow_data["monitor_service"] = self.monitor_service
# Start the monitor service
await self.monitor_service.start()
logger.info("Subscription monitor service started successfully")
except Exception as e:
logger.error(f"Failed to initialize monitor service: {e}")
# Don't fail the entire application if monitor service fails
logger.warning("Continuing without monitor service")
self.monitor_service = None
async def start(self):
"""Start bot polling"""
logger.info("Bot polling started successfully")
try:
await self.dp.start_polling(self.bot)
except Exception as e:
logger.error(f"Error during polling: {e}")
raise
finally:
await self.shutdown()
async def shutdown(self):
"""Shutdown all services"""
logger.info("Shutting down bot...")
# Stop monitor service first
if self.monitor_service:
try:
await self.monitor_service.stop()
logger.info("Monitor service stopped")
except Exception as e:
logger.error(f"Error stopping monitor service: {e}")
# Close API connection
if self.api:
try:
await self.api.close()
logger.info("API connection closed")
except Exception as e:
logger.error(f"Error closing API: {e}")
# Close database connection
if self.db:
try:
await self.db.close()
logger.info("Database connection closed")
except Exception as e:
logger.error(f"Error closing database: {e}")
# Close bot session
if self.bot:
try:
await self.bot.session.close()
logger.info("Bot session closed")
except Exception as e:
logger.error(f"Error closing bot session: {e}")
logger.info("Bot shutdown complete")
def exit_gracefully(self, signum, frame):
logging.getLogger(__name__).info(f"Получен сигнал {signum}. Корректное завершение работы...")
self.exit = True
async def main():
"""Main function"""
app = None
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(settings.LOG_FILE, encoding='utf-8'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
killer = GracefulExit()
signal.signal(signal.SIGINT, killer.exit_gracefully)
signal.signal(signal.SIGTERM, killer.exit_gracefully)
webhook_server = None
yookassa_server_task = None
monitoring_task = None
maintenance_task = None
polling_task = None
try:
app = BotApplication()
await app.initialize()
await app.start()
logger.info("📊 Инициализация базы данных...")
await init_db()
except KeyboardInterrupt:
logger.info("Bot stopped by user (Ctrl+C)")
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
if not skip_migration:
logger.info("🔧 Выполняем проверку и миграцию базы данных...")
try:
migration_success = await run_universal_migration()
if migration_success:
logger.info("✅ Миграция базы данных завершена успешно")
else:
logger.warning("⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск")
except Exception as migration_error:
logger.error(f"❌ Ошибка выполнения миграции: {migration_error}")
logger.warning("⚠️ Продолжаем запуск без миграции")
else:
logger.info("ℹ️ Миграция пропущена (SKIP_MIGRATION=true)")
logger.info("🤖 Настройка бота...")
bot, dp = await setup_bot()
monitoring_service.bot = bot
maintenance_service.set_bot(bot)
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
payment_service = PaymentService(bot)
if settings.TRIBUTE_ENABLED:
logger.info("🌐 Запуск Tribute webhook сервера...")
webhook_server = WebhookServer(bot)
await webhook_server.start()
else:
logger.info("ℹ️ Tribute отключен, webhook сервер не запускается")
if settings.is_yookassa_enabled():
logger.info("💳 Запуск YooKassa webhook сервера...")
yookassa_server_task = asyncio.create_task(
start_yookassa_webhook_server(payment_service)
)
else:
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
logger.info("📊 Запуск службы мониторинга...")
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
logger.info("🔧 Проверка службы техработ...")
if not maintenance_service._check_task or maintenance_service._check_task.done():
logger.info("🔧 Запуск службы техработ...")
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
else:
logger.info("🔧 Служба техработ уже запущена")
maintenance_task = None
logger.info("🔄 Запуск polling...")
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
logger.info("=" * 50)
logger.info("🎯 Активные webhook endpoints:")
if settings.TRIBUTE_ENABLED:
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_yookassa_enabled():
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
logger.info("=" * 50)
try:
while not killer.exit:
await asyncio.sleep(1)
if yookassa_server_task and yookassa_server_task.done():
exception = yookassa_server_task.exception()
if exception:
logger.error(f"YooKassa webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск YooKassa webhook сервера...")
yookassa_server_task = asyncio.create_task(
start_yookassa_webhook_server(payment_service)
)
if monitoring_task.done():
exception = monitoring_task.exception()
if exception:
logger.error(f"Служба мониторинга завершилась с ошибкой: {exception}")
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
if maintenance_task and maintenance_task.done():
exception = maintenance_task.exception()
if exception:
logger.error(f"Служба техработ завершилась с ошибкой: {exception}")
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
if polling_task.done():
exception = polling_task.exception()
if exception:
logger.error(f"Polling завершился с ошибкой: {exception}")
break
except Exception as e:
logger.error(f"Ошибка в основном цикле: {e}")
except Exception as e:
logger.error(f"Critical error in main: {e}")
import traceback
traceback.print_exc()
logger.error(f"❌ Критическая ошибка при запуске: {e}")
raise
finally:
if app:
await app.shutdown()
logger.info("🛑 Начинается корректное завершение работы...")
if yookassa_server_task and not yookassa_server_task.done():
logger.info("ℹ️ Остановка YooKassa webhook сервера...")
yookassa_server_task.cancel()
try:
await yookassa_server_task
except asyncio.CancelledError:
pass
if monitoring_task and not monitoring_task.done():
logger.info("ℹ️ Остановка службы мониторинга...")
monitoring_service.stop_monitoring()
monitoring_task.cancel()
try:
await monitoring_task
except asyncio.CancelledError:
pass
if maintenance_task and not maintenance_task.done():
logger.info("ℹ️ Остановка службы техработ...")
await maintenance_service.stop_monitoring()
maintenance_task.cancel()
try:
await maintenance_task
except asyncio.CancelledError:
pass
if polling_task and not polling_task.done():
logger.info("ℹ️ Остановка polling...")
polling_task.cancel()
try:
await polling_task
except asyncio.CancelledError:
pass
if webhook_server:
logger.info("ℹ️ Остановка Tribute webhook сервера...")
await webhook_server.stop()
if 'bot' in locals():
try:
await bot.session.close()
logger.info("✅ Сессия бота закрыта")
except Exception as e:
logger.error(f"Ошибка закрытия сессии бота: {e}")
logger.info("✅ Завершение работы бота завершено")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Bot stopped by user")
print("\n🛑 Бот остановлен пользователем")
except Exception as e:
logger.error(f"Fatal error: {e}")
print(f"❌ Критическая ошибка: {e}")
sys.exit(1)
-199
View File
@@ -1,199 +0,0 @@
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, User as TgUser, Message, CallbackQuery
from typing import Callable, Dict, Any, Awaitable
import logging
from database import Database, User
from config import Config
logger = logging.getLogger(__name__)
class DatabaseMiddleware(BaseMiddleware):
"""Middleware for database operations"""
def __init__(self, db: Database):
self.db = db
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
data['db'] = self.db
return await handler(event, data)
class UserMiddleware(BaseMiddleware):
"""Middleware for user management"""
def __init__(self, db: Database, config: Config):
self.db = db
self.config = config
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
telegram_user: TgUser = data.get('event_from_user')
if telegram_user and not telegram_user.is_bot:
try:
# Get or create user
user = await self.db.get_user_by_telegram_id(telegram_user.id)
if not user:
# Check if user is admin
is_admin = telegram_user.id in self.config.ADMIN_IDS
# Create new user
user = await self.db.create_user(
telegram_id=telegram_user.id,
username=telegram_user.username,
first_name=telegram_user.first_name,
last_name=telegram_user.last_name,
language=self.config.DEFAULT_LANGUAGE,
is_admin=is_admin
)
logger.info(f"Created new user: {telegram_user.id}")
else:
# Update user info if changed
updated = False
if user.username != telegram_user.username:
user.username = telegram_user.username
updated = True
if user.first_name != telegram_user.first_name:
user.first_name = telegram_user.first_name
updated = True
if user.last_name != telegram_user.last_name:
user.last_name = telegram_user.last_name
updated = True
# Check admin status
should_be_admin = telegram_user.id in self.config.ADMIN_IDS
if user.is_admin != should_be_admin:
user.is_admin = should_be_admin
updated = True
if updated:
await self.db.update_user(user)
data['user'] = user
data['lang'] = user.language if user else self.config.DEFAULT_LANGUAGE
except Exception as e:
logger.error(f"Error in UserMiddleware: {e}")
# Create a minimal user object with defaults if database fails
class FallbackUser:
def __init__(self, telegram_id: int, username: str = None, config: Config = None):
self.telegram_id = telegram_id
self.username = username
self.first_name = None
self.last_name = None
self.language = config.DEFAULT_LANGUAGE if config else 'ru'
self.balance = 0.0
self.is_admin = telegram_id in (config.ADMIN_IDS if config else [])
self.remnawave_uuid = None
# Try to use fallback, but if database is completely broken, set None
try:
fallback_user = FallbackUser(telegram_user.id, telegram_user.username, self.config)
data['user'] = fallback_user
data['lang'] = self.config.DEFAULT_LANGUAGE
except:
data['user'] = None
data['lang'] = self.config.DEFAULT_LANGUAGE
else:
data['user'] = None
data['lang'] = self.config.DEFAULT_LANGUAGE
return await handler(event, data)
class LoggingMiddleware(BaseMiddleware):
"""Middleware for logging"""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
telegram_user: TgUser = data.get('event_from_user')
if isinstance(event, Message):
username = f"@{telegram_user.username}" if telegram_user and telegram_user.username else "no_username"
user_id = telegram_user.id if telegram_user else "unknown"
text = event.text or "no_text"
logger.info(f"Message from {user_id} ({username}): {text}")
elif isinstance(event, CallbackQuery):
username = f"@{telegram_user.username}" if telegram_user and telegram_user.username else "no_username"
user_id = telegram_user.id if telegram_user else "unknown"
callback_data = event.data or "no_data"
logger.info(f"Callback from {user_id} ({username}): {callback_data}")
return await handler(event, data)
class ThrottlingMiddleware(BaseMiddleware):
"""Simple throttling middleware"""
def __init__(self, rate_limit: float = 1.0):
self.rate_limit = rate_limit
self.user_last_action = {}
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
telegram_user: TgUser = data.get('event_from_user')
if telegram_user:
import time
current_time = time.time()
user_id = telegram_user.id
if user_id in self.user_last_action:
if current_time - self.user_last_action[user_id] < self.rate_limit:
logger.warning(f"Throttling user {user_id}")
return
self.user_last_action[user_id] = current_time
return await handler(event, data)
class WorkflowDataMiddleware(BaseMiddleware):
"""Middleware to pass workflow data to handlers"""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
# Get dispatcher from event
if hasattr(event, 'bot'):
dp = getattr(event.bot, '_dispatcher', None)
if dp and hasattr(dp, 'workflow_data'):
# Add workflow data to handler data
workflow_data = dp.workflow_data
for key, value in workflow_data.items():
if key not in data: # Don't override existing data
data[key] = value
return await handler(event, data)
class BotMiddleware(BaseMiddleware):
"""Middleware to add bot instance to handler data"""
def __init__(self, bot):
self.bot = bot
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
data['bot'] = self.bot
return await handler(event, data)
+41
View File
@@ -0,0 +1,41 @@
[alembic]
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+67
View File
@@ -0,0 +1,67 @@
import asyncio
import sys
from pathlib import Path
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
sys.path.append(str(Path(__file__).parent.parent.parent))
from app.database.models import Base
from app.config import settings
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,29 @@
"""add_paid_price_to_subscription
Revision ID: 3d9b35c6bd8f
Revises:
Create Date: 2025-08-23 08:17:00.563340
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3d9b35c6bd8f'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('subscriptions', sa.Column('paid_price_kopeks', sa.Integer(), nullable=False, server_default='0'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('subscriptions', 'paid_price_kopeks')
# ### end Alembic commands ###
-1003
View File
File diff suppressed because it is too large Load Diff
+22 -33
View File
@@ -1,38 +1,27 @@
# Telegram Bot Framework
aiogram>=3.4.0
# Основные зависимости
aiogram==3.7.0
aiohttp==3.9.1
asyncpg==0.29.0
SQLAlchemy==2.0.25
alembic==1.13.1
aiosqlite==0.19.0
# Database
SQLAlchemy>=2.0.0
alembic>=1.12.0
# Дополнительные зависимости
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
redis==5.0.1
# PostgreSQL driver
asyncpg>=0.28.0
psycopg2-binary>=2.9.0
# YooKassa SDK
yookassa==3.0.0
# SQLite driver (fallback)
aiosqlite>=0.19.0
# Логирование и мониторинг
structlog==23.2.0
# HTTP Client
aiohttp>=3.8.0
aiofiles>=23.0.0
# Планировщик задач для техработ
APScheduler==3.10.4
# Redis (optional)
redis>=4.5.0
aioredis>=2.0.0
# Utilities
python-dotenv>=1.0.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
# Logging and monitoring
structlog>=23.0.0
# Date and time
python-dateutil>=2.8.0
# Cryptography
cryptography>=42.0.0
# JSON handling
orjson>=3.9.0
# Утилиты
python-dateutil==2.8.2
pytz==2023.4
cryptography>=41.0.0
-218
View File
@@ -1,218 +0,0 @@
#!/bin/bash
# === RemnaWave Bot Setup & Management Script ===
SERVICE_NAME="remnawave-bot"
BOT_FILE="main.py"
ENV_FILE=".env"
VENV_DIR="venv"
# Select language
echo "🌐 Select language / Выберите язык:"
echo "1) English"
echo "2) Русский"
read -p ">>> " LANG_CHOICE
case $LANG_CHOICE in
2) LANG_CODE="ru" ;;
*) LANG_CODE="en" ;;
esac
# === Internationalized messages ===
msg() {
case $1 in
check_python) [[ $LANG_CODE == "ru" ]] && echo "🔍 Проверка Python..." || echo "🔍 Checking Python..." ;;
python_missing) [[ $LANG_CODE == "ru" ]] && echo "❌ Python 3 не установлен. Установите Python 3.8+." || echo "❌ Python 3 is not installed. Please install Python 3.8+." ;;
version) echo "📋 Python version: $PYTHON_VERSION" ;;
creating_venv) [[ $LANG_CODE == "ru" ]] && echo "🔧 Создание виртуального окружения..." || echo "🔧 Creating virtual environment..." ;;
activating_venv) [[ $LANG_CODE == "ru" ]] && echo "🔧 Активация виртуального окружения..." || echo "🔧 Activating virtual environment..." ;;
upgrading_pip) [[ $LANG_CODE == "ru" ]] && echo "📦 Обновление pip..." || echo "📦 Upgrading pip..." ;;
installing_requirements) [[ $LANG_CODE == "ru" ]] && echo "📦 Установка зависимостей..." || echo "📦 Installing requirements..." ;;
env_missing) [[ $LANG_CODE == "ru" ]] && echo "⚠️ Файл .env не найден!" || echo "⚠️ .env file not found!" ;;
env_created) [[ $LANG_CODE == "ru" ]] && echo "📋 Создан .env файл. Пожалуйста, отредактируйте его." || echo "📋 .env created. Please edit it." ;;
validating) [[ $LANG_CODE == "ru" ]] && echo "🔍 Проверка конфигурации..." || echo "🔍 Validating configuration..." ;;
config_ok) [[ $LANG_CODE == "ru" ]] && echo "✅ Конфигурация прошла проверку." || echo "✅ Configuration validated successfully." ;;
creating_service) [[ $LANG_CODE == "ru" ]] && echo "🔧 Создание systemd-сервиса..." || echo "🔧 Creating systemd service..." ;;
service_created) [[ $LANG_CODE == "ru" ]] && echo "✅ Сервис создан и включён." || echo "✅ Service created and enabled." ;;
start_prompt) [[ $LANG_CODE == "ru" ]] && echo "🚀 Запуск бота..." || echo "🚀 Starting the bot..." ;;
already_running) [[ $LANG_CODE == "ru" ]] && echo "🟢 Бот уже запущен!" || echo "🟢 Bot is already running!" ;;
last_logs) [[ $LANG_CODE == "ru" ]] && echo "📄 Последние 20 строк лога:" || echo "📄 Last 20 log lines:" ;;
action_menu)
[[ $LANG_CODE == "ru" ]] && {
echo -e "\nЧто вы хотите сделать?"
echo "1) Остановить бота"
echo "2) Перезапустить бота"
echo "3) Выйти"
} || {
echo -e "\nWhat would you like to do?"
echo "1) Stop the bot"
echo "2) Restart the bot"
echo "3) Exit"
}
;;
restarting) [[ $LANG_CODE == "ru" ]] && echo "🔄 Перезапуск бота..." || echo "🔄 Restarting bot..." ;;
completed)
echo ""
[[ $LANG_CODE == "ru" ]] && {
echo "🎉 Установка завершена!"
echo "📋 Возможности бота:"
echo " ✅ Мультиязычный интерфейс"
echo " ✅ Управление балансом и подписками"
echo " ✅ Промокоды и поддержка"
echo ""
} || {
echo "🎉 Installation complete!"
echo "📋 Bot Features:"
echo " ✅ Multi-language support"
echo " ✅ Subscription and balance management"
echo " ✅ Promocode system and support"
echo ""
}
;;
esac
}
# === Check if bot is running ===
if systemctl is-active --quiet "$SERVICE_NAME"; then
msg already_running
msg last_logs
sudo journalctl -u "$SERVICE_NAME" -n 20 --no-pager
msg action_menu
read -p ">>> " choice
case $choice in
1)
[[ $LANG_CODE == "ru" ]] && echo "🛑 Остановка бота..." || echo "🛑 Stopping the bot..."
sudo systemctl stop "$SERVICE_NAME"
[[ $LANG_CODE == "ru" ]] && echo "✅ Бот остановлен." || echo "✅ Bot stopped."
exit 0
;;
2)
msg restarting
sudo systemctl restart "$SERVICE_NAME"
[[ $LANG_CODE == "ru" ]] && echo "✅ Бот перезапущен." || echo "✅ Bot restarted."
exit 0
;;
3)
[[ $LANG_CODE == "ru" ]] && echo "👋 Выход." || echo "👋 Exiting."
exit 0
;;
*)
[[ $LANG_CODE == "ru" ]] && echo "⚠️ Неверный выбор." || echo "⚠️ Invalid choice."
exit 1
;;
esac
fi
# === Install/Setup ===
msg check_python
if ! command -v python3 &> /dev/null; then
msg python_missing
exit 1
fi
PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
msg version
if [ ! -d "$VENV_DIR" ]; then
msg creating_venv
python3 -m venv "$VENV_DIR"
fi
msg activating_venv
source "$VENV_DIR/bin/activate"
msg upgrading_pip
pip install --upgrade pip
msg installing_requirements
pip install -r requirements.txt
# Create .env if needed
if [ ! -f "$ENV_FILE" ]; then
msg env_missing
cat > "$ENV_FILE" <<EOF
# Bot Configuration
BOT_TOKEN=
# RemnaWave API Configuration
REMNAWAVE_URL=
REMNAWAVE_MODE=
REMNAWAVE_TOKEN=
# Admin Configuration (comma-separated list of Telegram user IDs)
ADMIN_IDS=
# Support Configuration
SUPPORT_USERNAME=support
# Database Configuration (optional, defaults to SQLite)
# DATABASE_URL=sqlite+aiosqlite:///bot.db
# Trial Configuration
TRIAL_ENABLED=true
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_GB=2
TRIAL_SQUAD_UUID=
TRIAL_PRICE=0.0
EOF
msg env_created
echo ""
read -p "🔧 Press Enter after configuring .env / Нажмите Enter после настройки .env..."
fi
# Load env
export $(grep -v '^#' "$ENV_FILE" | xargs)
msg validating
if [[ -z "$BOT_TOKEN" || "$BOT_TOKEN" == "your_telegram_bot_token_here" ]]; then
echo "❌ BOT_TOKEN not set properly."
exit 1
fi
if [[ -z "$REMNAWAVE_URL" || "$REMNAWAVE_URL" == "https://your-panel.com" ]]; then
echo "❌ REMNAWAVE_URL not set properly."
exit 1
fi
if [[ -z "$REMNAWAVE_TOKEN" || "$REMNAWAVE_TOKEN" == "your_jwt_token_here" ]]; then
echo "❌ REMNAWAVE_TOKEN not set properly."
exit 1
fi
msg config_ok
# Create systemd service
read -p "🔧 Create systemd service? (y/n): " CREATE_SERVICE
if [[ "$CREATE_SERVICE" == "y" || "$CREATE_SERVICE" == "Y" ]]; then
msg creating_service
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
CURRENT_DIR=$(pwd)
CURRENT_USER=$(whoami)
sudo tee "$SERVICE_FILE" > /dev/null <<EOF
[Unit]
Description=RemnaWave Bot
After=network.target
[Service]
Type=simple
User=$CURRENT_USER
WorkingDirectory=$CURRENT_DIR
Environment=PATH=$CURRENT_DIR/$VENV_DIR/bin
EnvironmentFile=$CURRENT_DIR/.env
ExecStart=$CURRENT_DIR/$VENV_DIR/bin/python3 $BOT_FILE
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
msg service_created
fi
msg completed
msg start_prompt
python3 "$BOT_FILE"
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

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