Compare commits
884 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ae480430 | |||
| 0b222c06a1 | |||
| ff340e90d6 | |||
| 4125141d01 | |||
| 2f40ad53b5 | |||
| 2d3674eff7 | |||
| 560e60b856 | |||
| 7cf20234b8 | |||
| ecab0a0f45 | |||
| 587722ed5c | |||
| bd7d9b3d5e | |||
| 60787a2aa9 | |||
| 9675ac3c14 | |||
| ca9a2f5997 | |||
| 652eb4f5a7 | |||
| 6ffbad747d | |||
| af46c596a9 | |||
| 1fcc218f26 | |||
| 39a5d674c9 | |||
| bdfe6ae1cf | |||
| 06e6c65ada | |||
| 8a7e4ff1c9 | |||
| f0a1e1c338 | |||
| 843af67483 | |||
| 80d251c2d1 | |||
| a1c2907cce | |||
| c1369f113c | |||
| 8ea5bc5330 | |||
| 285dda4795 | |||
| fae423177b | |||
| 51bf43289d | |||
| 3fd87eee9a | |||
| ad14ab5d08 | |||
| aab68767fd | |||
| 84f10229da | |||
| 3a6ce31103 | |||
| da7675a853 | |||
| 09726f8df0 | |||
| 8072ce6954 | |||
| 1e870e49c8 | |||
| eeca712a3e | |||
| 29c3e455e4 | |||
| c23a777783 | |||
| 0439279602 | |||
| 27c81d451b | |||
| 339881f288 | |||
| ef6bfb8ea3 | |||
| 4bd33fbbe2 | |||
| 8a5f097808 | |||
| 239eb9705d | |||
| fee141ea2b | |||
| ec7c121e03 | |||
| 8b047fd18c | |||
| 0c04868bef | |||
| deaf16e6ae | |||
| 389cac3e0a | |||
| c04968818f | |||
| 3a391d79d2 | |||
| d1a8019c31 | |||
| 1c211b0a41 | |||
| 7c1ba6d92b | |||
| c0953eecf3 | |||
| 7f70826023 | |||
| a7aaa6fc88 | |||
| a72b3bf705 | |||
| 71656df0bd | |||
| 7d5cff0aa9 | |||
| 4e27396a09 | |||
| 52a72a11b0 | |||
| c17886489b | |||
| ade2f106ba | |||
| a096b2bc1a | |||
| de65390680 | |||
| bd5f92c121 | |||
| ad53a6bc09 | |||
| f3b2c8056e | |||
| 1e90903d54 | |||
| 8537875537 | |||
| a988e562a0 | |||
| d014a54181 | |||
| 4e5d57bf92 | |||
| 6f8ebbb9f0 | |||
| 131ac32596 | |||
| 56017d023c | |||
| 3afdc6cd31 | |||
| 52a89eac77 | |||
| 0cdd311832 | |||
| 1d5b52ef5b | |||
| 3f37be4509 | |||
| 1636af1585 | |||
| 127409955c | |||
| 423ce5e41b | |||
| 770b7e8358 | |||
| 0daf08ceed | |||
| 1cda2259c6 | |||
| 5879080f33 | |||
| cd3ad8bff9 | |||
| 033a748069 | |||
| de890dbd7f | |||
| 7c5ae09a6e | |||
| e587e3b087 | |||
| d698b3cfe7 | |||
| a01680fa2d | |||
| 531fc0fb22 | |||
| bb8e513598 | |||
| f56679cd9f | |||
| 9885e050a1 | |||
| 68be5cbe4b | |||
| 3315c83e0b | |||
| ca9b6f4063 | |||
| cc490b616f | |||
| 988f9eb4eb | |||
| c41eeb2c8b | |||
| 881963ca7b | |||
| db6733083c | |||
| 77851b090a | |||
| e0ada505aa | |||
| 3179f33b67 | |||
| 9bbd72b65f | |||
| 68ace1358b | |||
| f7934fd0fe | |||
| 15cbbc04ce | |||
| d4260106cb | |||
| 0a46bd18db | |||
| 0ccbad4077 | |||
| 013ce09846 | |||
| 736ec95572 | |||
| bddf36261d | |||
| cbd74049dd | |||
| 84f3db4548 | |||
| c07ccf0daf | |||
| 3ff4906214 | |||
| 1bc5133c95 | |||
| dcbad389b3 | |||
| dfea057a25 | |||
| 3a6bf029d1 | |||
| 667041558e | |||
| 01e51f2dfd | |||
| 483971d12f | |||
| fad93dab53 | |||
| ff13b7685b | |||
| 4281701957 | |||
| a0ff5dbcd4 | |||
| ed9ac80e68 | |||
| e9662b9b42 | |||
| ba3fc35a14 | |||
| 18471f0162 | |||
| 71073fd0e5 | |||
| 2e1ee6bb28 | |||
| 239f14acaf | |||
| e87a62c180 | |||
| cb275fc4b6 | |||
| 4042dee884 | |||
| 2d3dc8e024 | |||
| 530759fc81 | |||
| 0f29850d7f | |||
| e060c39790 | |||
| 612d7ad529 | |||
| a310e88905 | |||
| bda959e8d6 | |||
| bd9d12c185 | |||
| adf509dc81 | |||
| 22f5fc43dd | |||
| 49b10b2d4a | |||
| 2f218b7567 | |||
| 1bb0894894 | |||
| 39da15fec2 | |||
| 9ff66b9650 | |||
| c5353b46b1 | |||
| 25aa214ffa | |||
| 28279b7ec2 | |||
| 126170bee7 | |||
| 8ce7935d49 | |||
| f756c9a7f9 | |||
| a59905b98f | |||
| f0d74185a1 | |||
| 06937e026f | |||
| 65acaf7058 | |||
| 7fc574daf3 | |||
| a93eb8d2d1 | |||
| 9fbd15f700 | |||
| cec96d866c | |||
| 80031306a4 | |||
| 9783781bfb | |||
| aba7302833 | |||
| 61ce10c52e | |||
| f307ad03bf | |||
| f150bfad37 | |||
| 8c22df1f52 | |||
| f396ca7df4 | |||
| 0b1a2b7f68 | |||
| d92b9559ac | |||
| fb3942e29d | |||
| 6009d15a51 | |||
| e2469a14c9 | |||
| 4daacae0d9 | |||
| 47dbced347 | |||
| 49b129a8f4 | |||
| f8315ba18e | |||
| a9809e74c9 | |||
| 4fe61ec870 | |||
| 172ee773ee | |||
| b8edc4ec0d | |||
| d8f2f413a8 | |||
| 360a04fbe8 | |||
| 2613d42b57 | |||
| a06d55af38 | |||
| bf28617d99 | |||
| 878a28cb11 | |||
| ade062c7cd | |||
| 5d774bd1aa | |||
| 8d81bfcf94 | |||
| 095c3a8490 | |||
| f7c8c82775 | |||
| 3d75543cf0 | |||
| 1223948596 | |||
| f12964ecc8 | |||
| 693dce2e28 | |||
| 166050c581 | |||
| d499574c4d | |||
| 98363e2c28 | |||
| d87622417a | |||
| 8be1af5adb | |||
| 72a2bb7d04 | |||
| a847d4c1f6 | |||
| b860752d80 | |||
| 0a466b973b | |||
| 9ca0ca2db2 | |||
| af3f746ae1 | |||
| c64c53a26b | |||
| 765e912dd1 | |||
| f205acfb0a | |||
| 3c424143dc | |||
| ba87ffa0be | |||
| f211c93794 | |||
| 8994e3e9ba | |||
| 6018c07c0d | |||
| 41bfffdf12 | |||
| 1a06b88db9 | |||
| bd6d9013ae | |||
| 093d178bf8 | |||
| 6169f34911 | |||
| 3aca29777c | |||
| c7f2b0752b | |||
| e113b7e842 | |||
| 359bbaa31c | |||
| 862aeee558 | |||
| 4c3a3b91a6 | |||
| 199e78cb25 | |||
| 87845e2691 | |||
| 0c30d1ea30 | |||
| 2f64aa0961 | |||
| 2266609111 | |||
| 28e9cf6ab7 | |||
| 7c55c54f23 | |||
| 060d0a6970 | |||
| f0465a9ea4 | |||
| 29db1f4e56 | |||
| 755ef03f98 | |||
| 458b71ea28 | |||
| f1f409dc63 | |||
| 64d0f9fc2d | |||
| 01db9036f5 | |||
| 674e1b73f6 | |||
| 9bfb9bc711 | |||
| 96f905563f | |||
| 6b278de538 | |||
| c6fc770fc1 | |||
| e50f208064 | |||
| 637af5c216 | |||
| 08373be215 | |||
| 5e4c7e339a | |||
| 476dbcca93 | |||
| e8e4330b1c | |||
| d5c0146d48 | |||
| 83c461230c | |||
| f27a51639c | |||
| ab8d4f0340 | |||
| 1ee15bf6d3 | |||
| 56238ad93c | |||
| 2fa2c6278c | |||
| 519978f634 | |||
| 7095d8f015 | |||
| e8dcfa0687 | |||
| a0e12fa89a | |||
| 097be9aa0f | |||
| f50b5296f4 | |||
| 14e94053ee | |||
| 3c27fa4d90 | |||
| 2a78ace675 | |||
| 81774f87d8 | |||
| 6735c2b38f | |||
| 8bdc5a2332 | |||
| d26262bfbf | |||
| 48f2f01bdd | |||
| ef68a43589 | |||
| 733c1b72ec | |||
| c9ca360286 | |||
| 5aad1e025b | |||
| e811e7c877 | |||
| 932e9f292b | |||
| 03749574d6 | |||
| 1059a51688 | |||
| a1963d5b17 | |||
| 426e736cd5 | |||
| fb03cff403 | |||
| 40780f1184 | |||
| c76290b51e | |||
| 5b6dd3058e | |||
| c8d91a1ec1 | |||
| 21952f84eb | |||
| 7bdfaa312b | |||
| 1829a469e7 | |||
| ddd37a4e98 | |||
| 2804c701c0 | |||
| b3c85b2db5 | |||
| 987b8170c6 | |||
| ab49f3cd1e | |||
| 732e8f7b8b | |||
| 9273a4e15b | |||
| d89293f76a | |||
| 174462183d | |||
| 783927be37 | |||
| 98c68dd8dc | |||
| 13585eab54 | |||
| 169336da43 | |||
| 9ef875f6bf | |||
| 3f54706a72 | |||
| 835e5e6f7f | |||
| b292442e8d | |||
| 9faaf5c963 | |||
| 117e88fe4a | |||
| 96bf08a07c | |||
| 4523403de5 | |||
| 02682e88c1 | |||
| 323b9a7e18 | |||
| 100dc3730f | |||
| 919047317a | |||
| 6ae5b59ed1 | |||
| b3123e2e85 | |||
| fbe0ca790e | |||
| 785648ba7a | |||
| 06f8728efa | |||
| 99f102a06e | |||
| d759dd3a66 | |||
| 73dc22b0b8 | |||
| 5a082f0eb8 | |||
| 073d5a119b | |||
| 2dab106bef | |||
| ec8c7b7242 | |||
| a4b96a089f | |||
| 269f8f6220 | |||
| f1beda2b89 | |||
| 6a00db32ae | |||
| 276bc5eb40 | |||
| 8678afd055 | |||
| 31340ad4a5 | |||
| 98ce47ce71 | |||
| 3462f5b3b1 | |||
| 7ec606f93c | |||
| 7d13bf47aa | |||
| 87773806fc | |||
| dad6200ad8 | |||
| c3991162c2 | |||
| 08feb1f876 | |||
| 6f15070052 | |||
| 0c4e73ab92 | |||
| 48902c4968 | |||
| 3c715577a6 | |||
| 8e67c7a97a | |||
| c938d48026 | |||
| 99a4927f8a | |||
| df5b8dcdcd | |||
| d4a1a2e64d | |||
| 5e2df269a3 | |||
| a2056e0441 | |||
| a6bbd8f8fb | |||
| 9f4e0cf5f8 | |||
| c7e8365d3f | |||
| 49b10b490e | |||
| f26bca8627 | |||
| df139b3d34 | |||
| 05d1edf278 | |||
| a518df8ff0 | |||
| 0a13fe4f40 | |||
| 6410bd1085 | |||
| 55c4549cff | |||
| e898948073 | |||
| 5f1c8d8ac0 | |||
| 8d1f8f2355 | |||
| fbbbad608b | |||
| 03fc4abd2a | |||
| f280a37818 | |||
| f100ec62d4 | |||
| cd827612d9 | |||
| 50da2f82c8 | |||
| 4c814ec5f4 | |||
| fb8c02d5ab | |||
| 465ad3d1ad | |||
| 2bfaab7d16 | |||
| e356542df7 | |||
| a3dfbdce3b | |||
| a666e8d248 | |||
| 9ef8df22a8 | |||
| a1471c8b9a | |||
| a99a14922c | |||
| ce954ad775 | |||
| efba7c10d0 | |||
| cd10c131b2 | |||
| 3e4cb8dfa2 | |||
| 6471ab547c | |||
| c10bf07124 | |||
| 4e05882522 | |||
| 061b292d92 | |||
| 779a6c309a | |||
| c79c71f209 | |||
| d4dbce5606 | |||
| e53d99223d | |||
| ec7274e0c9 | |||
| 8fe631a98e | |||
| bd831bd92b | |||
| 9b31ba8f3d | |||
| 3759be333a | |||
| 555894b26c | |||
| bb636c003c | |||
| 40c7b4aebd | |||
| 028bd4888b | |||
| 27d64e361a | |||
| 6fc7dd49dd | |||
| b835ba1d65 | |||
| 811e985087 | |||
| c16733b93d | |||
| 23ab3c5198 | |||
| 9b8f121e69 | |||
| 86b1b95fb9 | |||
| 9418681a00 | |||
| 7ebd937e2c | |||
| 25c404f999 | |||
| 6df92bde2a | |||
| 60ff006fec | |||
| 0181f5e10c | |||
| 4bbcc60bbb | |||
| 8e2138e2aa | |||
| 70b9e8c10d | |||
| dc1eddb0df | |||
| 630551cc4b | |||
| 4f94b25182 | |||
| d3b240eae3 | |||
| e2883d6727 | |||
| 21757b37ab | |||
| 9190857ffa | |||
| a607443aa7 | |||
| 41969bc0c2 | |||
| 0d443afb87 | |||
| 3d85294d41 | |||
| 86896771a6 | |||
| 42d52990fa | |||
| 6296c8257f | |||
| 14a40cb1b6 | |||
| d0e3650de5 | |||
| df8ed666ae | |||
| 2217b7f800 | |||
| 924febc2f3 | |||
| 729ffb7952 | |||
| 1568ca7633 | |||
| 7d5e476580 | |||
| 9bcd187358 | |||
| a9cbb6272f | |||
| b524decfa7 | |||
| 5ee01f7735 | |||
| f46f2d6523 | |||
| 435dbf6cee | |||
| 94d38eeb71 | |||
| 0e7f6f90ed | |||
| f3789287dc | |||
| a8312d7c10 | |||
| 612f4a7978 | |||
| 2462347b5f | |||
| 3ec3c0d76b | |||
| 8224aa98f4 | |||
| bb9d9422fb | |||
| f3a184ff00 | |||
| 866e4586df | |||
| a30647893d | |||
| 73826312e5 | |||
| dae34dc0ef | |||
| 17d0c8c26f | |||
| 67831dc162 | |||
| ae84fbe141 | |||
| 8c9bd890e8 | |||
| e8c1956de5 | |||
| 1540a41d3a | |||
| 9cb61cb696 | |||
| 3511325285 | |||
| 62507c5d28 | |||
| 387df29a05 | |||
| 75dca7e1ad | |||
| 2d5e54d5ad | |||
| d62b2b0d6f | |||
| e273719ad1 | |||
| f6726f1c94 | |||
| 92ff4f661f | |||
| dfebc5e608 | |||
| 8e6121cd0b | |||
| 6c0d5714bd | |||
| ed29697434 | |||
| 07aebef82f | |||
| 7ce390fd50 | |||
| cebe392d1c | |||
| fd160f585a | |||
| 5c0516fbc0 | |||
| ba075326c1 | |||
| c32425341e | |||
| cc8df1eff3 | |||
| 49cda0791c | |||
| 60ea363a62 | |||
| f66619b225 | |||
| 5999402e1a | |||
| 3aebccc090 | |||
| ed577dd51a | |||
| e517487596 | |||
| f9441909a4 | |||
| f13a015bfa | |||
| 18bf946464 | |||
| e4c46b2488 | |||
| e0539b116c | |||
| acdf7af1d9 | |||
| d5b7828079 | |||
| 03be071edc | |||
| e8742f30a6 | |||
| 3d7727dbf2 | |||
| 5bff67860f | |||
| 429e967da2 | |||
| c3df3beeae | |||
| 4d664c15d7 | |||
| 53be365e38 | |||
| 3c3ffed32e | |||
| 015726b3a6 | |||
| 4a3267712b | |||
| 9b45aae410 | |||
| f43b391d0e | |||
| b09b7a0c84 | |||
| 541b44b933 | |||
| b21fce0eff | |||
| 017aa27c0f | |||
| 496978e8a2 | |||
| b3711a590d | |||
| 631a0f2c4c | |||
| 503f7ca455 | |||
| 80c771b7bc | |||
| 6e344d350b | |||
| 7738bc83af | |||
| 6f7a1eecf6 | |||
| bf147a736a | |||
| ea3decf1ac | |||
| b1c0315a6e | |||
| f6fa7e8f41 | |||
| 33d4d7994c | |||
| 0ce4ce2b08 | |||
| 3a9163e919 | |||
| 376cff748b | |||
| d5cef080da | |||
| 33e31213b3 | |||
| 8077ba0c35 | |||
| 67ad3847f8 | |||
| 1eda042b34 | |||
| 961ff46f43 | |||
| 535ec34a1b | |||
| fe474fd0bf | |||
| bfc737c1d3 | |||
| ce0eb32d4e | |||
| 090df2e3ea | |||
| 01d9df2867 | |||
| 334a14c80c | |||
| 3d952570af | |||
| bf9567fe6f | |||
| 6c4c39b454 | |||
| d39eb3f79f | |||
| 7f0ce61126 | |||
| 775056da6f | |||
| a15ecb375b | |||
| 3dea941c51 | |||
| 410f0aba95 | |||
| 1ec13fbf63 | |||
| 012408b7cd | |||
| fdca75b153 | |||
| 40c235cdac | |||
| 7ea8fa735c | |||
| 758141dbc4 | |||
| 3c37fa45e6 | |||
| 35a80f4585 | |||
| 57e033c7c6 | |||
| d05d070faa | |||
| 3f3829a745 | |||
| 21bb89e5ea | |||
| fad5ecbb89 | |||
| 2c64c57edd | |||
| 117c637756 | |||
| 488f161322 | |||
| a55ef684e6 | |||
| 90234ba01e | |||
| cd83361050 | |||
| 08cda481ba | |||
| 45f998d134 | |||
| 33ee5b4ec4 | |||
| 545bbaafa5 | |||
| cc957460bb | |||
| cd51dab845 | |||
| 74923515cc | |||
| b3f3c415df | |||
| 0b9de65cbc | |||
| 1c26171b52 | |||
| a6bc0afdd3 | |||
| 966cfe6565 | |||
| d5b1c5805e | |||
| a4ce5ff9ca | |||
| 8e4b70578b | |||
| 5367874062 | |||
| 321696b5b5 | |||
| b642a5550c | |||
| 0e97632099 | |||
| 4a3c6c4124 | |||
| 3bf9c143d4 | |||
| 285e5e256e | |||
| 5b06b89d7a | |||
| 7e749fa096 | |||
| 13ac3f0e74 | |||
| 32c551999c | |||
| ff2c09d3be | |||
| 4c97ac4497 | |||
| 86516186d9 | |||
| e0581bdc3c | |||
| 0b7d8d41f6 | |||
| 4ca46537f9 | |||
| 75f2d0f1ce | |||
| 51424bf256 | |||
| 4cce2f1efb | |||
| 5102286963 | |||
| 24c8f25e6e | |||
| de4796ac1c | |||
| 95a8da5f19 | |||
| 76f1313550 | |||
| 58d22200cf | |||
| eb8b09e658 | |||
| 25cf635b6f | |||
| a54c1001f9 | |||
| 6140d7042a | |||
| d0135a2328 | |||
| 1048734548 | |||
| 62116fd5ba | |||
| 37a905f056 | |||
| bce9061401 | |||
| 118b282a2b | |||
| 6c250ed36f | |||
| 3842aeacca | |||
| 1d1afbe1da | |||
| d4db55ea66 | |||
| 354964060c | |||
| 5bc8d8e52c | |||
| 8f3e8bacc4 | |||
| 61c94e17a4 | |||
| 77c52c1197 | |||
| c65c703e00 | |||
| 7560e802cb | |||
| a5a90a806b | |||
| aa0a271d14 | |||
| 5d4e51c862 | |||
| e6057bb93c | |||
| 714bdd0d7c | |||
| 41f3eb76c1 | |||
| e9c58b3f76 | |||
| 3d6976ff6e | |||
| ee978dff7b | |||
| 0bcf5c5f43 | |||
| 2ceedd6587 | |||
| 2ded1a4a9e | |||
| d551a7267f | |||
| 3ddf6b35c6 | |||
| c0d3c70683 | |||
| 7267433d99 | |||
| 09f3c4d27f | |||
| 3083915bc8 | |||
| 5c72c04d27 | |||
| 826149da3d | |||
| f090be328f | |||
| ecbfcd2b54 | |||
| 2d21b1c542 | |||
| c63438af5e | |||
| 8e47013ae2 | |||
| 92fa0ebcc1 | |||
| f1d23616f1 | |||
| d554f2d801 | |||
| 1fe4608604 | |||
| bea534b352 | |||
| e993c19a34 | |||
| 2deb5d1bc1 | |||
| fb1b0bbcd5 | |||
| 0308dd6c58 | |||
| 860bf7fc7a | |||
| c24024db18 | |||
| 7d330838e0 | |||
| feed3fff0d | |||
| 4ddaafd052 | |||
| 140194a0ad | |||
| 7c6adae87f | |||
| 03deb91a25 | |||
| ea2caa98ae | |||
| dab69b9229 | |||
| 0ffa86ff36 | |||
| 3b08b71472 | |||
| 6c7b1353df | |||
| 9e009d2ded | |||
| dc6a829f0f | |||
| 3a3a4aa9aa | |||
| 99cd22d76b | |||
| 0a920bffe6 | |||
| 55fbc65231 | |||
| f508ac151b | |||
| abec12eed1 | |||
| 9b50fbbe05 | |||
| 9311e7c11d | |||
| 625972da87 | |||
| d3293615b0 | |||
| eef1fcc705 | |||
| ffe8259025 | |||
| bac42b51e1 | |||
| 510ec2990d | |||
| dc06056cc7 | |||
| 103d6e8854 | |||
| 466f1e66cc | |||
| 5de90b20f6 | |||
| 28a26b61ac | |||
| 2182b47666 | |||
| 347c62ec8b | |||
| 736e4c6cae | |||
| 92a07e2acf | |||
| 83af139d01 | |||
| e76e297d22 | |||
| b317368df2 | |||
| 572170b1cc | |||
| 1adeb53a0d | |||
| 3e5811ec11 | |||
| e26a76c8cf | |||
| 1d5755e415 | |||
| 32beec7515 | |||
| feb2250f37 | |||
| da36db39c5 | |||
| fd0fa71f85 | |||
| 04250b38bb | |||
| deb6978572 | |||
| d2a26ead1e | |||
| 9e40f406ee | |||
| 2cdfaf9356 | |||
| 66af399bc0 | |||
| 5fa69dec3e | |||
| 3d5f3303d1 | |||
| 10883e92b1 | |||
| e2d28333ec | |||
| 6d55d4832f | |||
| 46c563873f | |||
| 10c5d3c408 | |||
| 942080c460 | |||
| 6a0869ca0a | |||
| d9992dd2f2 | |||
| c8c9631518 | |||
| 943f29f227 | |||
| 80a069077c | |||
| e344e55e6a | |||
| a69947b69a | |||
| 926f593f50 | |||
| f13734a4e9 | |||
| 2107166926 | |||
| 60e5601690 | |||
| 0957d9ef88 | |||
| eb481c57ec | |||
| e0b1666f5d | |||
| f2afc088af | |||
| 971d59b2c6 | |||
| 83fe58056f | |||
| 5f107922d0 | |||
| a2ea6b7bf7 | |||
| 1e0e728840 | |||
| cea6ed3f95 | |||
| f15705ed47 | |||
| 0b9c13ae4b | |||
| d0c8ae02f0 | |||
| 4eda7401b9 | |||
| 0c2f3c9399 | |||
| 541ecae48c | |||
| 82a551f1d4 | |||
| fd2253135d | |||
| d0820aa47e | |||
| 09c5800f49 | |||
| 52aa2dc6a8 | |||
| 6418ee7735 | |||
| 25aef89b4f | |||
| 77d17f97d3 | |||
| d5c1ec3aea | |||
| 97a7384381 | |||
| 27b7bfccba | |||
| ab15624a93 | |||
| a9c5d8ed69 | |||
| 234b90796e | |||
| 5f018ba22f | |||
| e11cbbcc18 | |||
| ba4d30d8db | |||
| d11431769c | |||
| d1d1bbb3e4 | |||
| 823f30882a | |||
| d36f389469 | |||
| add686a4d5 | |||
| 59be2d8f02 | |||
| aa3db49f65 | |||
| c101cb3d3f | |||
| 63912af43b | |||
| d19263dd79 | |||
| f8c9c59a03 | |||
| 5595f7a954 | |||
| bac4527945 | |||
| 51da8bcb72 | |||
| 8aa6e7e245 | |||
| 5029b35d14 | |||
| 2412027d9b | |||
| ddff5219e5 | |||
| 346bc18b58 | |||
| c256ae3664 | |||
| 2b47358a9e | |||
| e0b041ce44 | |||
| 3f64dee927 | |||
| 67c5ebeabd | |||
| 84bc8e96cc | |||
| 2d30cec7c6 | |||
| 5f5f66178b | |||
| e9fadec0ac | |||
| 10008e51ff | |||
| 3098610d17 | |||
| 7a2566d320 | |||
| 01ba8ca53e | |||
| 2a0a2dccff | |||
| 66a6f32d4c | |||
| 02b3f61953 | |||
| dc08526255 | |||
| 2ca339610b | |||
| ffe65937c1 | |||
| 4b6f03eef5 | |||
| d2f530a7fe | |||
| 44ea4b6b13 | |||
| ebc8421a70 | |||
| 526f142dea | |||
| ca431d36e6 | |||
| 68a81bdd89 | |||
| 68a3d713ef | |||
| 50d3d08e38 | |||
| 9a2fec4d8f | |||
| e68b1d2092 | |||
| 6dfedef81e | |||
| 317e39c823 | |||
| 0072a3cb71 | |||
| 8d021c1530 | |||
| 23606dc2d4 | |||
| 0838c4196d | |||
| 65605a0de2 | |||
| a20ac80281 | |||
| 02e83fb0d0 | |||
| eddad0db6e | |||
| ffad0afb61 | |||
| 83eed8e1cd | |||
| 0c7c8abade | |||
| a064890ed8 | |||
| f310f4a35e | |||
| 0268e09882 | |||
| 449814d0df | |||
| a3d3577103 | |||
| a3086fdd2c | |||
| daafc6a41e | |||
| c27ee116f7 | |||
| 5167cde443 | |||
| 34542d3a9c | |||
| e73d1df4cc | |||
| f9a95c3d48 | |||
| 2dd2b5fdbf | |||
| 896f1db9d4 | |||
| a1bd2d775f | |||
| 8cbe23f9ed |
@@ -0,0 +1,46 @@
|
||||
# Git files
|
||||
.git
|
||||
.gitignore
|
||||
.github/
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Development files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Docker files (исключаем из копирования в образ)
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
+232
-52
@@ -1,61 +1,241 @@
|
||||
# Bot Configuration
|
||||
# ===============================================
|
||||
# 🤖 REMNAWAVE BEDOLAGA BOT CONFIGURATION
|
||||
# ===============================================
|
||||
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
# настройки реф программы юзернейм без @!
|
||||
BOT_USERNAME=
|
||||
REFERRAL_FIRST_REWARD=100.0
|
||||
REFERRAL_REFERRED_BONUS=100.0
|
||||
REFERRAL_THRESHOLD=200.0
|
||||
REFERRAL_PERCENTAGE=0.2
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# RemnaWave API Configuration
|
||||
REMNAWAVE_URL=https://example.com
|
||||
REMNAWAVE_MODE=remote
|
||||
REMNAWAVE_TOKEN=
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
DATABASE_MODE=auto
|
||||
|
||||
# Admin Configuration (comma-separated list of Telegram user IDs)
|
||||
ADMIN_IDS=123445567,12342123
|
||||
# Основной URL (можно оставить пустым для автоматического выбора)
|
||||
DATABASE_URL=
|
||||
|
||||
# Support Configuration
|
||||
SUPPORT_USERNAME=support
|
||||
# PostgreSQL настройки (для Docker и кастомных установок)
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=remnawave_bot
|
||||
POSTGRES_USER=remnawave_user
|
||||
POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
TRIAL_ENABLED=true # Включить триал
|
||||
TRIAL_DURATION_DAYS=3 # Дней триала
|
||||
TRIAL_TRAFFIC_GB=2 # Лимит трафика у триал подписки
|
||||
TRIAL_SQUAD_UUID= # UUID сквада из панели remnawave /dashboard/management/internal-squads
|
||||
TRIAL_PRICE=0.0 # Оставить 0!
|
||||
# SQLite настройки (для локального запуска)
|
||||
SQLITE_PATH=./data/bot.db
|
||||
|
||||
# Monitor Service Settings (дополнительные настройки)
|
||||
MONITOR_CHECK_INTERVAL=21600 # Промежуток проверики (3600 - будет раз в час проверять и слать уведомления)
|
||||
MONITOR_DAILY_CHECK_HOUR=10 # Ежедневный чек в 10 утра
|
||||
MONITOR_WARNING_DAYS=2 # За сколько начинать до окончания подписки слать уведомления
|
||||
DELETE_EXPIRED_TRIAL_DAYS=1 # Через сколько дней после истечения удалять триал подписки
|
||||
DELETE_EXPIRED_REGULAR_DAYS=7 # Через сколько дней после истечения удалять обычные подписки
|
||||
AUTO_DELETE_ENABLED=true # Включить автоматическое удаление при ежедневной проверке
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
LUCKY_GAME_ENABLED=true # Включить/выключить игру удачи
|
||||
LUCKY_GAME_REWARD=50.0 # Размер награды за выигрыш в рублях
|
||||
LUCKY_GAME_NUMBERS=30 # Общее количество чисел для выбора (1-30)
|
||||
LUCKY_GAME_WINNING_COUNT=25 # Количество выигрышных чисел из общего количества
|
||||
# ===== 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
|
||||
|
||||
# ================================
|
||||
# TELEGRAM STARS НАСТРОЙКИ
|
||||
# ================================
|
||||
# Включить/выключить пополнение через Telegram Stars
|
||||
STARS_ENABLED=true
|
||||
# Курсы обмена звезд на рубли
|
||||
# Формат: STARS_{количество_звезд}_RATE={рубли}
|
||||
# 100 звёзд
|
||||
STARS_100_RATE=110
|
||||
# 150 звёзд
|
||||
STARS_150_RATE=165
|
||||
# 250 звёзд
|
||||
STARS_250_RATE=280
|
||||
# 350 звёзд
|
||||
STARS_350_RATE=355
|
||||
# 500 звёзд
|
||||
STARS_500_RATE=550
|
||||
# 750 звёзд
|
||||
STARS_750_RATE=800
|
||||
# 1000 звёзд
|
||||
STARS_1000_RATE=1000
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
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
|
||||
|
||||
# Уведомления
|
||||
REFERRAL_NOTIFICATIONS_ENABLED=true
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
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_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}
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=true
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
CRYPTOBOT_WEBHOOK_PORT=8083
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - открывает ссылку подписки напрямую (режим 4)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
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
|
||||
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
name: BedolagaBot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
@@ -0,0 +1,111 @@
|
||||
name: BedolagaBot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем версию и теги
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.2.6-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.2.6-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.2.6-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "should_push=${{ github.event_name != 'pull_request' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "=== Информация о сборке ==="
|
||||
echo "Версия: $VERSION"
|
||||
echo "Коммит: $(git rev-parse --short HEAD)"
|
||||
echo "Теги: $TAGS"
|
||||
echo "Push: ${{ github.event_name != 'pull_request' }}"
|
||||
echo "==========================="
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ steps.version.outputs.should_push }}
|
||||
tags: ${{ steps.version.outputs.tags }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
BUILD_DATE=${{ steps.version.outputs.build_date }}
|
||||
VCS_REF=${{ steps.version.outputs.short_sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
org.opencontainers.image.created=${{ steps.version.outputs.build_date }}
|
||||
org.opencontainers.image.revision=${{ steps.version.outputs.short_sha }}
|
||||
org.opencontainers.image.title=Bedolaga RemnaWave Bot
|
||||
org.opencontainers.image.description=Telegram bot for RemnaWave VPN service
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
|
||||
- name: Build Summary
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "## 🚀 Docker Hub Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Registry** | Docker Hub |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📋 Опубликованные теги:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.version.outputs.tags }}" | tr ',' '\n' >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🔗 Ссылки:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [Docker Hub Repository](https://hub.docker.com/r/fr1ngg/remnawave-bedolaga-telegram-bot)" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,154 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- 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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: |
|
||||
network=host
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.2.6"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.2.6-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.2.6-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем, нужно ли пушить образ
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
|
||||
echo "should_push=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ PR из того же репозитория - будем пушить"
|
||||
else
|
||||
echo "should_push=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ PR из внешнего форка - только build без push"
|
||||
fi
|
||||
else
|
||||
echo "should_push=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Push/Tag - будем пушить"
|
||||
fi
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
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' }}
|
||||
type=raw,value=${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: ${{ steps.version.outputs.should_push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
BUILD_DATE=${{ steps.version.outputs.build_date }}
|
||||
VCS_REF=${{ steps.version.outputs.short_sha }}
|
||||
cache-from: |
|
||||
type=gha
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: |
|
||||
type=gha,mode=max
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
build-contexts: |
|
||||
alpine=docker-image://alpine:latest
|
||||
|
||||
- name: Generate security report
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true'
|
||||
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 }}
|
||||
|
||||
- name: Build Summary
|
||||
if: steps.version.outputs.should_push == 'true'
|
||||
run: |
|
||||
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Build Summary (No Push)
|
||||
if: steps.version.outputs.should_push == 'false'
|
||||
run: |
|
||||
echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Статус** | ✅ Собран успешно (без публикации) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY
|
||||
+31
-2
@@ -1,2 +1,31 @@
|
||||
/screens
|
||||
/assets
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
!.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
|
||||
|
||||
+49
-33
@@ -1,46 +1,62 @@
|
||||
# Use Python 3.11 slim image
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
# 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 \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -r botuser && useradd -r -g botuser botuser
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY 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
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/logs /app/data && \
|
||||
chown -R botuser:botuser /app
|
||||
ARG VERSION="v2.2.6"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
# Switch to non-root user
|
||||
USER botuser
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
RUN groupadd -g 1000 app && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=app:app . .
|
||||
|
||||
RUN mkdir -p logs data && \
|
||||
chown -R app:app /app logs data
|
||||
|
||||
USER app
|
||||
|
||||
ENV PYTHONPATH=/app \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
VERSION=${VERSION} \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8081 8082
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.revision="${VCS_REF}" \
|
||||
org.opencontainers.image.source="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
|
||||
org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
|
||||
org.opencontainers.image.vendor="fr1ngg"
|
||||
|
||||
# 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"]
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -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}}"
|
||||
-7741
File diff suppressed because it is too large
Load Diff
+41
@@ -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
|
||||
@@ -1,201 +0,0 @@
|
||||
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:
|
||||
|
||||
@staticmethod
|
||||
async def handle_api_error(callback: CallbackQuery, error: Exception,
|
||||
operation: str, user_language: str = 'ru',
|
||||
fallback_keyboard=None) -> bool:
|
||||
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]:
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
async def check_api_health(api: RemnaWaveAPI) -> Dict[str, Any]:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def handle_api_errors(operation_name: str):
|
||||
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.get('api')
|
||||
fallback_keyboard = None
|
||||
|
||||
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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
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,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates
|
||||
)
|
||||
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)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
admin_updates.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}")
|
||||
+584
@@ -0,0 +1,584 @@
|
||||
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"
|
||||
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
|
||||
DATABASE_URL: str = ""
|
||||
|
||||
POSTGRES_HOST: str = "postgres"
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_DB: str = "remnawave_bot"
|
||||
POSTGRES_USER: str = "remnawave_user"
|
||||
POSTGRES_PASSWORD: str = "secure_password_123"
|
||||
|
||||
SQLITE_PATH: str = "./data/bot.db"
|
||||
|
||||
DATABASE_MODE: str = "auto"
|
||||
|
||||
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_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}"
|
||||
|
||||
CRYPTOBOT_ENABLED: bool = False
|
||||
CRYPTOBOT_API_TOKEN: Optional[str] = None
|
||||
CRYPTOBOT_WEBHOOK_SECRET: Optional[str] = None
|
||||
CRYPTOBOT_BASE_URL: str = "https://pay.crypt.bot"
|
||||
CRYPTOBOT_TESTNET: bool = False
|
||||
CRYPTOBOT_WEBHOOK_PATH: str = "/cryptobot-webhook"
|
||||
CRYPTOBOT_WEBHOOK_PORT: int = 8083
|
||||
CRYPTOBOT_DEFAULT_ASSET: str = "USDT"
|
||||
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
|
||||
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
|
||||
|
||||
VERSION_CHECK_ENABLED: bool = True
|
||||
VERSION_CHECK_REPO: str = "fr1ngg/remnawave-bedolaga-telegram-bot"
|
||||
VERSION_CHECK_INTERVAL_HOURS: int = 1
|
||||
|
||||
@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 get_database_url(self) -> str:
|
||||
if self.DATABASE_URL and self.DATABASE_URL.strip():
|
||||
return self.DATABASE_URL
|
||||
|
||||
mode = self.DATABASE_MODE.lower()
|
||||
|
||||
if mode == "sqlite":
|
||||
return self._get_sqlite_url()
|
||||
elif mode == "postgresql":
|
||||
return self._get_postgresql_url()
|
||||
elif mode == "auto":
|
||||
if os.getenv("DOCKER_ENV") == "true" or os.path.exists("/.dockerenv"):
|
||||
return self._get_postgresql_url()
|
||||
else:
|
||||
return self._get_sqlite_url()
|
||||
else:
|
||||
return self._get_auto_database_url()
|
||||
|
||||
def _get_sqlite_url(self) -> str:
|
||||
sqlite_path = Path(self.SQLITE_PATH)
|
||||
sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return f"sqlite+aiosqlite:///{sqlite_path.absolute()}"
|
||||
|
||||
def _get_postgresql_url(self) -> str:
|
||||
return (f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}")
|
||||
|
||||
def _get_auto_database_url(self) -> str:
|
||||
if (os.getenv("DOCKER_ENV") == "true" or
|
||||
os.path.exists("/.dockerenv")):
|
||||
return self._get_postgresql_url()
|
||||
else:
|
||||
return self._get_sqlite_url()
|
||||
|
||||
def is_postgresql(self) -> bool:
|
||||
"""Проверяет, используется ли PostgreSQL"""
|
||||
return "postgresql" in self.get_database_url()
|
||||
|
||||
def is_sqlite(self) -> bool:
|
||||
"""Проверяет, используется ли SQLite"""
|
||||
return "sqlite" in self.get_database_url()
|
||||
|
||||
def is_admin(self, user_id: int) -> bool:
|
||||
return user_id in self.get_admin_ids()
|
||||
|
||||
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} ₽"
|
||||
|
||||
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_cryptobot_enabled(self) -> bool:
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
self.CRYPTOBOT_API_TOKEN is not None)
|
||||
|
||||
def get_cryptobot_base_url(self) -> str:
|
||||
if self.CRYPTOBOT_TESTNET:
|
||||
return "https://testnet-pay.crypt.bot"
|
||||
return self.CRYPTOBOT_BASE_URL
|
||||
|
||||
def get_cryptobot_assets(self) -> List[str]:
|
||||
try:
|
||||
assets = self.CRYPTOBOT_ASSETS.strip()
|
||||
if not assets:
|
||||
return ["USDT", "TON"]
|
||||
return [asset.strip() for asset in assets.split(',') if asset.strip()]
|
||||
except (ValueError, AttributeError):
|
||||
return ["USDT", "TON"]
|
||||
|
||||
def get_cryptobot_invoice_expires_seconds(self) -> int:
|
||||
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
|
||||
|
||||
def is_maintenance_mode(self) -> bool:
|
||||
return self.MAINTENANCE_MODE
|
||||
|
||||
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 hasattr(self, f'PRICE_{period}_DAYS'):
|
||||
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 hasattr(self, f'PRICE_{period}_DAYS'):
|
||||
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_admin_notifications_chat_id(self) -> Optional[int]:
|
||||
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(self.ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def is_admin_notifications_enabled(self) -> bool:
|
||||
return (self.ADMIN_NOTIFICATIONS_ENABLED and
|
||||
self.get_admin_notifications_chat_id() is not None)
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
return {
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
|
||||
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
|
||||
"referred_user_reward": self.REFERRED_USER_REWARD
|
||||
}
|
||||
|
||||
def is_referral_notifications_enabled(self) -> bool:
|
||||
return self.REFERRAL_NOTIFICATIONS_ENABLED
|
||||
|
||||
def get_traffic_packages(self) -> List[Dict]:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
packages = []
|
||||
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
|
||||
|
||||
logger.info(f"CONFIG STRING: '{config_str}'")
|
||||
|
||||
if not config_str:
|
||||
logger.info("CONFIG EMPTY, USING FALLBACK")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
logger.info("PARSING CONFIG...")
|
||||
|
||||
for package_config in config_str.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
continue
|
||||
|
||||
parts = package_config.split(':')
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
gb = int(parts[0])
|
||||
price = int(parts[1])
|
||||
enabled = parts[2].lower() == 'true'
|
||||
|
||||
packages.append({
|
||||
"gb": gb,
|
||||
"price": price,
|
||||
"enabled": enabled
|
||||
})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.info(f"PARSED {len(packages)} packages from config")
|
||||
return packages if packages else self._get_fallback_traffic_packages()
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"ERROR PARSING CONFIG: {e}")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
def is_version_check_enabled(self) -> bool:
|
||||
return self.VERSION_CHECK_ENABLED
|
||||
|
||||
def get_version_check_repo(self) -> str:
|
||||
return self.VERSION_CHECK_REPO
|
||||
|
||||
def get_version_check_interval(self) -> int:
|
||||
return self.VERSION_CHECK_INTERVAL_HOURS
|
||||
|
||||
def _get_fallback_traffic_packages(self) -> List[Dict]:
|
||||
try:
|
||||
if self.TRAFFIC_PACKAGES_CONFIG.strip():
|
||||
packages = []
|
||||
for package_config in self.TRAFFIC_PACKAGES_CONFIG.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
continue
|
||||
|
||||
parts = package_config.split(':')
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
gb = int(parts[0])
|
||||
price = int(parts[1])
|
||||
enabled = parts[2].lower() == 'true'
|
||||
|
||||
packages.append({
|
||||
"gb": gb,
|
||||
"price": price,
|
||||
"enabled": enabled
|
||||
})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if packages:
|
||||
return packages
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return [
|
||||
{"gb": 5, "price": self.PRICE_TRAFFIC_5GB, "enabled": True},
|
||||
{"gb": 10, "price": self.PRICE_TRAFFIC_10GB, "enabled": True},
|
||||
{"gb": 25, "price": self.PRICE_TRAFFIC_25GB, "enabled": True},
|
||||
{"gb": 50, "price": self.PRICE_TRAFFIC_50GB, "enabled": True},
|
||||
{"gb": 100, "price": self.PRICE_TRAFFIC_100GB, "enabled": True},
|
||||
{"gb": 250, "price": self.PRICE_TRAFFIC_250GB, "enabled": True},
|
||||
{"gb": 500, "price": self.PRICE_TRAFFIC_500GB, "enabled": True},
|
||||
{"gb": 1000, "price": self.PRICE_TRAFFIC_1000GB, "enabled": True},
|
||||
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
|
||||
]
|
||||
|
||||
def get_traffic_price(self, gb: int) -> int:
|
||||
packages = self.get_traffic_packages()
|
||||
|
||||
for package in packages:
|
||||
if package["gb"] == gb and package["enabled"]:
|
||||
return package["price"]
|
||||
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
|
||||
|
||||
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
|
||||
if finite_packages:
|
||||
max_package = max(finite_packages, key=lambda x: x["gb"])
|
||||
|
||||
if gb > max_package["gb"]:
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
else:
|
||||
return max_package["price"]
|
||||
|
||||
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
|
||||
if suitable_packages:
|
||||
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
|
||||
return nearest_package["price"]
|
||||
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
|
||||
return 0
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
"extra": "ignore"
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
|
||||
settings._original_database_url = settings.DATABASE_URL
|
||||
settings.DATABASE_URL = settings.get_database_url()
|
||||
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import CryptoBotPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_cryptobot_payment(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
invoice_id: str,
|
||||
amount: str,
|
||||
asset: str,
|
||||
status: str = "active",
|
||||
description: Optional[str] = None,
|
||||
payload: Optional[str] = None,
|
||||
bot_invoice_url: Optional[str] = None,
|
||||
mini_app_invoice_url: Optional[str] = None,
|
||||
web_app_invoice_url: Optional[str] = None
|
||||
) -> CryptoBotPayment:
|
||||
|
||||
payment = CryptoBotPayment(
|
||||
user_id=user_id,
|
||||
invoice_id=invoice_id,
|
||||
amount=amount,
|
||||
asset=asset,
|
||||
status=status,
|
||||
description=description,
|
||||
payload=payload,
|
||||
bot_invoice_url=bot_invoice_url,
|
||||
mini_app_invoice_url=mini_app_invoice_url,
|
||||
web_app_invoice_url=web_app_invoice_url
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}")
|
||||
return payment
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_invoice_id(
|
||||
db: AsyncSession,
|
||||
invoice_id: str
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.invoice_id == invoice_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_cryptobot_payment_status(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
status: str,
|
||||
paid_at: Optional[datetime] = None
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
if status == "paid" and paid_at:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Обновлен статус CryptoBot платежа {invoice_id}: {status}")
|
||||
return payment
|
||||
|
||||
|
||||
async def link_cryptobot_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
transaction_id: int
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}")
|
||||
return payment
|
||||
|
||||
|
||||
async def get_user_cryptobot_payments(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[CryptoBotPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.where(CryptoBotPayment.user_id == user_id)
|
||||
.order_by(CryptoBotPayment.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_pending_cryptobot_payments(
|
||||
db: AsyncSession,
|
||||
older_than_hours: int = 24
|
||||
) -> List[CryptoBotPayment]:
|
||||
|
||||
from datetime import timedelta
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(
|
||||
and_(
|
||||
CryptoBotPayment.status == "active",
|
||||
CryptoBotPayment.created_at < cutoff_time
|
||||
)
|
||||
)
|
||||
.order_by(CryptoBotPayment.created_at)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from app.database.models import SentNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def notification_sent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(SentNotification).where(
|
||||
SentNotification.user_id == user_id,
|
||||
SentNotification.subscription_id == subscription_id,
|
||||
SentNotification.notification_type == notification_type,
|
||||
SentNotification.days_before == days_before,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def record_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
) -> None:
|
||||
notification = SentNotification(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
notification_type=notification_type,
|
||||
days_before=days_before,
|
||||
)
|
||||
db.add(notification)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
SentNotification.subscription_id == subscription_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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. При возникновении вопросов обращайтесь в техническую поддержку.
|
||||
|
||||
Используя сервис, вы соглашаетесь с данными правилами.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,777 @@
|
||||
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.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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)
|
||||
await clear_notifications(db, subscription.id)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,366 @@
|
||||
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]:
|
||||
|
||||
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]:
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=24)
|
||||
|
||||
exact_external_id = f"donation_{payment_id}"
|
||||
|
||||
query = select(Transaction).options(selectinload(Transaction.user)).where(
|
||||
and_(
|
||||
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
|
||||
Transaction.external_id == exact_external_id,
|
||||
Transaction.amount_kopeks == amount_kopeks,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= cutoff_time
|
||||
)
|
||||
).join(User).where(User.telegram_id == user_telegram_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
transaction = result.scalar_one_or_none()
|
||||
|
||||
if transaction:
|
||||
logger.info(f"🔍 Найден дубликат платежа в течение 24ч: {transaction.id}")
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
async def create_unique_tribute_transaction(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
payment_id: str,
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
) -> Transaction:
|
||||
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,359 @@
|
||||
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,
|
||||
has_made_first_topup=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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import UserMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_user_message(
|
||||
db: AsyncSession,
|
||||
message_text: str,
|
||||
created_by: int,
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0
|
||||
) -> UserMessage:
|
||||
message = UserMessage(
|
||||
message_text=message_text,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
created_by=created_by
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
logger.info(f"✅ Создано сообщение ID {message.id} пользователем {created_by}")
|
||||
return message
|
||||
|
||||
|
||||
async def get_user_message_by_id(db: AsyncSession, message_id: int) -> Optional[UserMessage]:
|
||||
result = await db.execute(
|
||||
select(UserMessage).where(UserMessage.id == message_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_active_user_messages(db: AsyncSession) -> List[UserMessage]:
|
||||
result = await db.execute(
|
||||
select(UserMessage)
|
||||
.where(UserMessage.is_active == True)
|
||||
.order_by(UserMessage.sort_order.asc(), UserMessage.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_random_active_message(db: AsyncSession) -> Optional[str]:
|
||||
active_messages = await get_active_user_messages(db)
|
||||
|
||||
if not active_messages:
|
||||
return None
|
||||
|
||||
random_message = random.choice(active_messages)
|
||||
return random_message.message_text
|
||||
|
||||
|
||||
async def get_all_user_messages(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50
|
||||
) -> List[UserMessage]:
|
||||
result = await db.execute(
|
||||
select(UserMessage)
|
||||
.order_by(UserMessage.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_messages_count(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(UserMessage.id)))
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def update_user_message(
|
||||
db: AsyncSession,
|
||||
message_id: int,
|
||||
message_text: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
sort_order: Optional[int] = None
|
||||
) -> Optional[UserMessage]:
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
if message_text is not None:
|
||||
message.message_text = message_text
|
||||
|
||||
if is_active is not None:
|
||||
message.is_active = is_active
|
||||
|
||||
if sort_order is not None:
|
||||
message.sort_order = sort_order
|
||||
|
||||
message.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
logger.info(f"📝 Обновлено сообщение ID {message_id}")
|
||||
return message
|
||||
|
||||
|
||||
async def toggle_user_message_status(
|
||||
db: AsyncSession,
|
||||
message_id: int
|
||||
) -> Optional[UserMessage]:
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
message.is_active = not message.is_active
|
||||
message.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
status_text = "активировано" if message.is_active else "деактивировано"
|
||||
logger.info(f"🔄 Сообщение ID {message_id} {status_text}")
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def delete_user_message(db: AsyncSession, message_id: int) -> bool:
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
return False
|
||||
|
||||
await db.delete(message)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🗑️ Удалено сообщение ID {message_id}")
|
||||
return True
|
||||
|
||||
|
||||
async def get_user_messages_stats(db: AsyncSession) -> dict:
|
||||
total_result = await db.execute(select(func.count(UserMessage.id)))
|
||||
total_messages = total_result.scalar()
|
||||
|
||||
active_result = await db.execute(
|
||||
select(func.count(UserMessage.id)).where(UserMessage.is_active == True)
|
||||
)
|
||||
active_messages = active_result.scalar()
|
||||
|
||||
return {
|
||||
"total_messages": total_messages,
|
||||
"active_messages": active_messages,
|
||||
"inactive_messages": total_messages - active_messages
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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("✅ Подключение к базе данных закрыто")
|
||||
@@ -0,0 +1,662 @@
|
||||
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"
|
||||
CRYPTOBOT = "cryptobot"
|
||||
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 CryptoBotPayment(Base):
|
||||
__tablename__ = "cryptobot_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
invoice_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
amount = Column(String(50), nullable=False)
|
||||
asset = Column(String(10), nullable=False)
|
||||
|
||||
status = Column(String(50), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
payload = Column(Text, nullable=True)
|
||||
|
||||
bot_invoice_url = Column(Text, nullable=True)
|
||||
mini_app_invoice_url = Column(Text, nullable=True)
|
||||
web_app_invoice_url = Column(Text, nullable=True)
|
||||
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="cryptobot_payments")
|
||||
transaction = relationship("Transaction", backref="cryptobot_payment")
|
||||
|
||||
@property
|
||||
def amount_float(self) -> float:
|
||||
try:
|
||||
return float(self.amount)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
return self.status == "paid"
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == "active"
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.status == "expired"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
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 SentNotification(Base):
|
||||
__tablename__ = "sent_notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
|
||||
notification_type = Column(String(50), nullable=False)
|
||||
days_before = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", backref="sent_notifications")
|
||||
subscription = relationship("Subscription", backref="sent_notifications")
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
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")
|
||||
|
||||
class UserMessage(Base):
|
||||
__tablename__ = "user_messages"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_text = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
creator = relationship("User", backref="created_messages")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserMessage(id={self.id}, active={self.is_active}, text='{self.message_text[:50]}...')>"
|
||||
@@ -0,0 +1,660 @@
|
||||
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_cryptobot_payments_table():
|
||||
"""Создание таблицы cryptobot_payments"""
|
||||
table_exists = await check_table_exists('cryptobot_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица cryptobot_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE cryptobot_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
invoice_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
asset VARCHAR(10) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
description TEXT NULL,
|
||||
payload TEXT NULL,
|
||||
bot_invoice_url TEXT NULL,
|
||||
mini_app_invoice_url TEXT NULL,
|
||||
web_app_invoice_url TEXT NULL,
|
||||
paid_at DATETIME NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id);
|
||||
CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id);
|
||||
CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE cryptobot_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
invoice_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
asset VARCHAR(10) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
description TEXT NULL,
|
||||
payload TEXT NULL,
|
||||
bot_invoice_url TEXT NULL,
|
||||
mini_app_invoice_url TEXT NULL,
|
||||
web_app_invoice_url TEXT NULL,
|
||||
paid_at TIMESTAMP NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id);
|
||||
CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id);
|
||||
CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE cryptobot_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
invoice_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
asset VARCHAR(10) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
description TEXT NULL,
|
||||
payload TEXT NULL,
|
||||
bot_invoice_url TEXT NULL,
|
||||
mini_app_invoice_url TEXT NULL,
|
||||
web_app_invoice_url TEXT NULL,
|
||||
paid_at DATETIME NULL,
|
||||
transaction_id INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id);
|
||||
CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id);
|
||||
CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица cryptobot_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы cryptobot_payments: {e}")
|
||||
return False
|
||||
|
||||
async def create_user_messages_table():
|
||||
"""Создание таблицы user_messages"""
|
||||
table_exists = await check_table_exists('user_messages')
|
||||
if table_exists:
|
||||
logger.info("Таблица user_messages уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE user_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_text TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_by INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_messages_active ON user_messages(is_active);
|
||||
CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE user_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
message_text TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_by INTEGER NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_messages_active ON user_messages(is_active);
|
||||
CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE user_messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
message_text TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_messages_active ON user_messages(is_active);
|
||||
CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица user_messages успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы user_messages: {e}")
|
||||
return False
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'postgresql':
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE user_messages
|
||||
DROP CONSTRAINT IF EXISTS user_messages_created_by_fkey;
|
||||
"""))
|
||||
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE user_messages
|
||||
ADD CONSTRAINT user_messages_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
logger.info("Обновлен внешний ключ user_messages.created_by")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка обновления FK user_messages: {e}")
|
||||
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE promocodes
|
||||
DROP CONSTRAINT IF EXISTS promocodes_created_by_fkey;
|
||||
"""))
|
||||
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE promocodes
|
||||
ADD CONSTRAINT promocodes_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
logger.info("Обновлен внешний ключ promocodes.created_by")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка обновления FK promocodes: {e}")
|
||||
|
||||
logger.info("Внешние ключи обновлены для безопасного удаления пользователей")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления внешних ключей: {e}")
|
||||
return False
|
||||
|
||||
async def add_referral_system_columns():
|
||||
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
column_exists = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
if not column_exists:
|
||||
logger.info("Добавление колонки has_made_first_topup в таблицу users")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'BOOLEAN DEFAULT 0'
|
||||
else:
|
||||
column_def = 'BOOLEAN DEFAULT FALSE'
|
||||
|
||||
await conn.execute(text(f"ALTER TABLE users ADD COLUMN has_made_first_topup {column_def}"))
|
||||
logger.info("Колонка has_made_first_topup успешно добавлена")
|
||||
|
||||
logger.info("Обновление существующих пользователей...")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
update_sql = """
|
||||
UPDATE users
|
||||
SET has_made_first_topup = 1
|
||||
WHERE balance_kopeks > 0 OR has_had_paid_subscription = 1
|
||||
"""
|
||||
else:
|
||||
update_sql = """
|
||||
UPDATE users
|
||||
SET has_made_first_topup = TRUE
|
||||
WHERE balance_kopeks > 0 OR has_had_paid_subscription = TRUE
|
||||
"""
|
||||
|
||||
result = await conn.execute(text(update_sql))
|
||||
updated_count = result.rowcount
|
||||
|
||||
logger.info(f"Обновлено {updated_count} пользователей с has_made_first_topup = TRUE")
|
||||
logger.info("✅ Миграция реферальной системы завершена")
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.info("Колонка has_made_first_topup уже существует")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка миграции реферальной системы: {e}")
|
||||
return False
|
||||
|
||||
async def create_subscription_conversions_table():
|
||||
"""Создание таблицы subscription_conversions"""
|
||||
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}")
|
||||
|
||||
# Миграция реферальной системы
|
||||
referral_migration_success = await add_referral_system_columns()
|
||||
if not referral_migration_success:
|
||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||
|
||||
# Создание таблицы CryptoBot payments
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===")
|
||||
cryptobot_created = await create_cryptobot_payments_table()
|
||||
if cryptobot_created:
|
||||
logger.info("✅ Таблица CryptoBot payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
|
||||
|
||||
# Создание таблицы user_messages
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===")
|
||||
user_messages_created = await create_user_messages_table()
|
||||
if user_messages_created:
|
||||
logger.info("✅ Таблица user_messages готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей user_messages")
|
||||
|
||||
# Обновление внешних ключей
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
logger.info("✅ Внешние ключи обновлены")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с обновлением внешних ключей")
|
||||
|
||||
# Создание таблицы конверсий подписок
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ КОНВЕРСИЙ ПОДПИСОК ===")
|
||||
conversions_created = await create_subscription_conversions_table()
|
||||
if conversions_created:
|
||||
logger.info("✅ Таблица subscription_conversions готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
|
||||
|
||||
# Проверка и исправление дублирующихся подписок
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
|
||||
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("✅ CryptoBot таблица готова")
|
||||
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,
|
||||
"cryptobot_table": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False
|
||||
}
|
||||
|
||||
# Проверка колонки реферальной системы
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
# Проверка таблицы CryptoBot
|
||||
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
|
||||
|
||||
# Проверка таблицы конверсий подписок
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
|
||||
# Проверка дублирующихся подписок
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(text("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT user_id, COUNT(*) as count
|
||||
FROM subscriptions
|
||||
GROUP BY user_id
|
||||
HAVING COUNT(*) > 1
|
||||
) as dups
|
||||
"""))
|
||||
duplicates_count = duplicates_check.fetchone()[0]
|
||||
status["subscription_duplicates"] = (duplicates_count == 0)
|
||||
|
||||
# Вывод результатов
|
||||
check_names = {
|
||||
"has_made_first_topup_column": "Колонка реферальной системы",
|
||||
"cryptobot_table": "Таблица CryptoBot payments",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
check_name = check_names.get(check_key, check_key)
|
||||
icon = "✅" if check_status else "❌"
|
||||
logger.info(f"{icon} {check_name}: {'OK' if check_status else 'ТРЕБУЕТ ВНИМАНИЯ'}")
|
||||
|
||||
all_good = all(status.values())
|
||||
if all_good:
|
||||
logger.info("🎉 Все миграции выполнены успешно!")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
conversions_count = await conn.execute(text("SELECT COUNT(*) FROM subscription_conversions"))
|
||||
users_count = await conn.execute(text("SELECT COUNT(*) FROM users"))
|
||||
|
||||
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
|
||||
Vendored
+167
@@ -0,0 +1,167 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import aiohttp
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CryptoBotService:
|
||||
|
||||
def __init__(self):
|
||||
self.api_token = settings.CRYPTOBOT_API_TOKEN
|
||||
self.base_url = settings.get_cryptobot_base_url()
|
||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.api_token:
|
||||
logger.error("CryptoBot API token не настроен")
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/api/{endpoint}"
|
||||
headers = {
|
||||
'Crypto-Pay-API-Token': self.api_token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=data if data else None
|
||||
) as response:
|
||||
|
||||
response_data = await response.json()
|
||||
|
||||
if response.status == 200 and response_data.get('ok'):
|
||||
return response_data.get('result')
|
||||
else:
|
||||
logger.error(f"CryptoBot API ошибка: {response_data}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запроса к CryptoBot API: {e}")
|
||||
return None
|
||||
|
||||
async def get_me(self) -> Optional[Dict[str, Any]]:
|
||||
return await self._make_request('GET', 'getMe')
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: str,
|
||||
asset: str = "USDT",
|
||||
description: Optional[str] = None,
|
||||
payload: Optional[str] = None,
|
||||
expires_in: Optional[int] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
data = {
|
||||
'currency_type': 'crypto',
|
||||
'asset': asset,
|
||||
'amount': amount
|
||||
}
|
||||
|
||||
if description:
|
||||
data['description'] = description
|
||||
|
||||
if payload:
|
||||
data['payload'] = payload
|
||||
|
||||
if expires_in:
|
||||
data['expires_in'] = expires_in
|
||||
|
||||
result = await self._make_request('POST', 'createInvoice', data)
|
||||
|
||||
if result:
|
||||
logger.info(f"Создан CryptoBot invoice {result.get('invoice_id')} на {amount} {asset}")
|
||||
|
||||
return result
|
||||
|
||||
async def get_invoices(
|
||||
self,
|
||||
asset: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
count: int = 100
|
||||
) -> Optional[list]:
|
||||
|
||||
data = {
|
||||
'offset': offset,
|
||||
'count': count
|
||||
}
|
||||
|
||||
if asset:
|
||||
data['asset'] = asset
|
||||
|
||||
if status:
|
||||
data['status'] = status
|
||||
|
||||
return await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
async def get_balance(self) -> Optional[list]:
|
||||
return await self._make_request('GET', 'getBalance')
|
||||
|
||||
async def get_exchange_rates(self) -> Optional[list]:
|
||||
return await self._make_request('GET', 'getExchangeRates')
|
||||
|
||||
def verify_webhook_signature(self, body: str, signature: str) -> bool:
|
||||
|
||||
if not self.webhook_secret:
|
||||
logger.warning("CryptoBot webhook secret не настроен")
|
||||
return True
|
||||
|
||||
try:
|
||||
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
|
||||
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ CryptoBot webhook подпись валидна")
|
||||
else:
|
||||
logger.error("❌ Неверная подпись CryptoBot webhook")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи CryptoBot webhook: {e}")
|
||||
return False
|
||||
|
||||
async def process_webhook(self, webhook_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
update_type = webhook_data.get('update_type')
|
||||
|
||||
if update_type == 'invoice_paid':
|
||||
invoice_data = webhook_data.get('payload', {})
|
||||
|
||||
return {
|
||||
'event_type': 'payment',
|
||||
'payment_id': str(invoice_data.get('invoice_id')),
|
||||
'amount': invoice_data.get('amount'),
|
||||
'asset': invoice_data.get('asset'),
|
||||
'status': 'paid',
|
||||
'user_payload': invoice_data.get('payload'),
|
||||
'paid_at': invoice_data.get('paid_at'),
|
||||
'payment_system': 'cryptobot'
|
||||
}
|
||||
|
||||
logger.warning(f"Неизвестный тип CryptoBot webhook: {update_type}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки CryptoBot webhook: {e}")
|
||||
return None
|
||||
Vendored
+620
@@ -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
|
||||
Vendored
+112
@@ -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} звезд (~{int(amount_rubles)}₽) "
|
||||
f"для {chat_id}, курс: {int(settings.get_stars_rate())}₽/⭐"
|
||||
)
|
||||
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"(~{int(amount_rubles)}₽), курс: {int(settings.get_stars_rate())}₽/⭐"
|
||||
)
|
||||
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
|
||||
Vendored
+161
@@ -0,0 +1,161 @@
|
||||
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.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.api_key:
|
||||
logger.warning("API key не настроен, пропускаем проверку")
|
||||
return True
|
||||
|
||||
try:
|
||||
expected_signature = hmac.new(
|
||||
self.api_key.encode(),
|
||||
payload.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись Tribute webhook проверена успешно")
|
||||
else:
|
||||
logger.error("❌ Неверная подпись Tribute webhook")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи webhook: {e}")
|
||||
return False
|
||||
|
||||
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
|
||||
Vendored
+239
@@ -0,0 +1,239 @@
|
||||
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)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
|
||||
return self.app
|
||||
|
||||
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"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска webhook сервера: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
|
||||
try:
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
logger.info("Webhook сайт остановлен")
|
||||
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("Webhook runner очищен")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки 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, trbt-signature, Crypto-Pay-API-Signature',
|
||||
}
|
||||
)
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
logger.info(f"Получен Tribute webhook: {request.method} {request.path}")
|
||||
logger.info(f"Headers: {dict(request.headers)}")
|
||||
|
||||
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('trbt-signature')
|
||||
logger.info(f"Signature: {signature}")
|
||||
|
||||
if not signature:
|
||||
logger.error("Отсутствует заголовок подписи Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "missing_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
if settings.TRIBUTE_API_KEY:
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
tribute_api = TributeAPI()
|
||||
if not tribute_api.verify_webhook_signature(payload, signature):
|
||||
logger.error("Неверная подпись Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
result = await self.tribute_service.process_webhook(payload)
|
||||
|
||||
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 _cryptobot_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
logger.info(f"Получен CryptoBot webhook: {request.method} {request.path}")
|
||||
logger.info(f"Headers: {dict(request.headers)}")
|
||||
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Получен пустой CryptoBot webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "empty_body"},
|
||||
status=400
|
||||
)
|
||||
|
||||
payload = raw_body.decode('utf-8')
|
||||
logger.info(f"CryptoBot Payload: {payload}")
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
logger.info(f"CryptoBot данные: {webhook_data}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Ошибка парсинга CryptoBot JSON: {e}")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_json"},
|
||||
status=400
|
||||
)
|
||||
|
||||
signature = request.headers.get('Crypto-Pay-API-Signature')
|
||||
logger.info(f"CryptoBot Signature: {signature}")
|
||||
|
||||
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
cryptobot_service = CryptoBotService()
|
||||
if not cryptobot_service.verify_webhook_signature(payload, signature):
|
||||
logger.error("Неверная подпись CryptoBot webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await payment_service.process_cryptobot_webhook(db, webhook_data)
|
||||
|
||||
if result:
|
||||
logger.info(f"CryptoBot webhook обработан успешно")
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
else:
|
||||
logger.error("Ошибка обработки CryptoBot webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "processing_failed"},
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка обработки CryptoBot webhook: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "internal_error", "message": str(e)},
|
||||
status=500
|
||||
)
|
||||
|
||||
async def _health_check(self, request: web.Request) -> web.Response:
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "payment-webhooks",
|
||||
"tribute_enabled": settings.TRIBUTE_ENABLED,
|
||||
"cryptobot_enabled": settings.is_cryptobot_enabled(),
|
||||
"port": settings.TRIBUTE_WEBHOOK_PORT,
|
||||
"tribute_path": settings.TRIBUTE_WEBHOOK_PATH,
|
||||
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None
|
||||
})
|
||||
Vendored
+243
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Инициализация админских обработчиков
|
||||
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,434 @@
|
||||
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()
|
||||
waiting_for_notification_message = 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()
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
rw_service = RemnaWaveService()
|
||||
panel_status = await rw_service.get_panel_status_summary()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса панели: {e}")
|
||||
panel_status = {"description": "❓ Не удалось проверить", "has_issues": True}
|
||||
|
||||
status_emoji = "🔧" if status_info["is_active"] else "✅"
|
||||
status_text = "Включен" if status_info["is_active"] else "Выключен"
|
||||
|
||||
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']}"
|
||||
|
||||
panel_info = f"\n🌐 <b>Панель Remnawave:</b> {panel_status['description']}"
|
||||
if panel_status.get("response_time"):
|
||||
panel_info += f"\n⚡ <b>Время отклика:</b> {panel_status['response_time']}с"
|
||||
|
||||
message_text = f"""
|
||||
🔧 <b>Управление техническими работами</b>
|
||||
|
||||
{status_emoji} <b>Режим техработ:</b> {status_text}
|
||||
{api_emoji} <b>API Remnawave:</b> {api_text}
|
||||
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
|
||||
⏱️ <b>Интервал проверки:</b> {status_info['check_interval']}с
|
||||
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
|
||||
{panel_info}
|
||||
{enabled_info}
|
||||
{last_check_info}
|
||||
{failures_info}
|
||||
|
||||
ℹ️ <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"],
|
||||
panel_status.get("has_issues", False)
|
||||
)
|
||||
)
|
||||
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 check_panel_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("Проверка статуса панели...", show_alert=False)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
rw_service = RemnaWaveService()
|
||||
|
||||
status_data = await rw_service.check_panel_health()
|
||||
|
||||
status_text = {
|
||||
"online": "🟢 Панель работает нормально",
|
||||
"offline": "🔴 Панель недоступна",
|
||||
"degraded": "🟡 Панель работает со сбоями"
|
||||
}.get(status_data["status"], "❓ Статус неизвестен")
|
||||
|
||||
message_parts = [
|
||||
f"🌐 <b>Статус панели Remnawave</b>\n",
|
||||
f"{status_text}",
|
||||
f"⚡ Время отклика: {status_data.get('response_time', 0)}с",
|
||||
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
|
||||
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
|
||||
]
|
||||
|
||||
if status_data.get("api_error"):
|
||||
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
await callback.message.answer(message, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
await callback.message.answer(f"❌ Ошибка проверки статуса: {str(e)}")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_manual_notification(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
await state.set_state(MaintenanceStates.waiting_for_notification_message)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="🟢 Онлайн", callback_data="manual_notify_online"),
|
||||
types.InlineKeyboardButton(text="🔴 Офлайн", callback_data="manual_notify_offline")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🟡 Проблемы", callback_data="manual_notify_degraded"),
|
||||
types.InlineKeyboardButton(text="🔧 Обслуживание", callback_data="manual_notify_maintenance")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📢 <b>Ручная отправка уведомления</b>\n\nВыберите статус для уведомления:",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_manual_notification(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
status_map = {
|
||||
"manual_notify_online": "online",
|
||||
"manual_notify_offline": "offline",
|
||||
"manual_notify_degraded": "degraded",
|
||||
"manual_notify_maintenance": "maintenance"
|
||||
}
|
||||
|
||||
status = status_map.get(callback.data)
|
||||
if not status:
|
||||
await callback.answer("Неизвестный статус")
|
||||
return
|
||||
|
||||
await state.update_data(notification_status=status)
|
||||
|
||||
status_names = {
|
||||
"online": "🟢 Онлайн",
|
||||
"offline": "🔴 Офлайн",
|
||||
"degraded": "🟡 Проблемы",
|
||||
"maintenance": "🔧 Обслуживание"
|
||||
}
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📢 <b>Отправка уведомления: {status_names[status]}</b>\n\n"
|
||||
f"Введите сообщение для уведомления или отправьте /skip для отправки без дополнительного текста:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_notification_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
|
||||
if current_state != MaintenanceStates.waiting_for_notification_message:
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
status = data.get("notification_status")
|
||||
|
||||
if not status:
|
||||
await message.answer("Ошибка: статус не выбран")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
notification_message = ""
|
||||
if message.text and message.text != "/skip":
|
||||
notification_message = message.text[:300]
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
rw_service = RemnaWaveService()
|
||||
|
||||
success = await rw_service.send_manual_status_notification(
|
||||
message.bot,
|
||||
status,
|
||||
notification_message
|
||||
)
|
||||
|
||||
if success:
|
||||
await message.answer("✅ Уведомление отправлено")
|
||||
else:
|
||||
await message.answer("❌ Ошибка отправки уведомления")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки ручного уведомления: {e}")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
"Вернуться к панели техработ:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def back_to_admin_panel(
|
||||
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(
|
||||
check_panel_status,
|
||||
F.data == "maintenance_check_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
send_manual_notification,
|
||||
F.data == "maintenance_manual_notify"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_manual_notification,
|
||||
F.data.startswith("manual_notify_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
back_to_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_maintenance_reason,
|
||||
MaintenanceStates.waiting_for_reason
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_notification_message,
|
||||
MaintenanceStates.waiting_for_notification_message
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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>
|
||||
• Общая: {int(stats['total_revenue_rubles'])} ₽
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
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"{int(server.price_rubles)} ₽" 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"{int(server.price_rubles)} ₽" 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"{int(server.price_rubles)} ₽" 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"{int(server.price_rubles)} ₽" 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"{int(price_rubles)} ₽" 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>
|
||||
• Общая выручка: {int(stats['total_revenue_rubles'])} ₽
|
||||
• Средняя цена за сервер: {int(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1))} ₽
|
||||
|
||||
<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"{int(server.price_rubles)} ₽" 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_"))
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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_")
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.version_service import version_service
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_updates_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Проверить обновления",
|
||||
callback_data="admin_updates_check"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📋 Информация о версии",
|
||||
callback_data="admin_updates_info"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Открыть репозиторий",
|
||||
url=f"https://github.com/{version_service.repo}/releases"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="◀️ Назад",
|
||||
callback_data="admin_panel"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_version_info_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить",
|
||||
callback_data="admin_updates_info"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="◀️ К обновлениям",
|
||||
callback_data="admin_updates"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_updates_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
version_info = await version_service.get_version_info()
|
||||
|
||||
current_version = version_info['current_version']
|
||||
has_updates = version_info['has_updates']
|
||||
total_newer = version_info['total_newer']
|
||||
last_check = version_info['last_check']
|
||||
|
||||
status_icon = "🆕" if has_updates else "✅"
|
||||
status_text = f"Доступно {total_newer} обновлений" if has_updates else "Актуальная версия"
|
||||
|
||||
last_check_text = ""
|
||||
if last_check:
|
||||
last_check_text = f"\n🕐 Последняя проверка: {last_check.strftime('%d.%m.%Y %H:%M')}"
|
||||
|
||||
message = f"""🔄 <b>СИСТЕМА ОБНОВЛЕНИЙ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{current_version}</code>
|
||||
{status_icon} <b>Статус:</b> {status_text}
|
||||
|
||||
🔗 <b>Репозиторий:</b> {version_service.repo}{last_check_text}
|
||||
|
||||
ℹ️ Система автоматически проверяет обновления каждый час и отправляет уведомления о новых версиях."""
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_updates_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка показа меню обновлений: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки меню обновлений", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def check_updates(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("🔄 Проверяю обновления...")
|
||||
|
||||
try:
|
||||
has_updates, newer_releases = await version_service.check_for_updates(force=True)
|
||||
|
||||
if not has_updates:
|
||||
message = f"""✅ <b>ОБНОВЛЕНИЯ НЕ НАЙДЕНЫ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>
|
||||
🎯 <b>Статус:</b> У вас установлена последняя версия
|
||||
|
||||
🔗 <b>Репозиторий:</b> {version_service.repo}"""
|
||||
|
||||
else:
|
||||
updates_list = []
|
||||
for i, release in enumerate(newer_releases[:5]):
|
||||
icon = version_service.format_version_display(release).split()[0]
|
||||
updates_list.append(
|
||||
f"{i+1}. {icon} <code>{release.tag_name}</code> • {release.formatted_date}"
|
||||
)
|
||||
|
||||
updates_text = "\n".join(updates_list)
|
||||
more_text = f"\n\n📋 И еще {len(newer_releases) - 5} обновлений..." if len(newer_releases) > 5 else ""
|
||||
|
||||
message = f"""🆕 <b>НАЙДЕНЫ ОБНОВЛЕНИЯ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>
|
||||
🎯 <b>Доступно обновлений:</b> {len(newer_releases)}
|
||||
|
||||
📋 <b>Последние версии:</b>
|
||||
{updates_text}{more_text}
|
||||
|
||||
🔗 <b>Репозиторий:</b> {version_service.repo}"""
|
||||
|
||||
keyboard = get_updates_keyboard(db_user.language)
|
||||
|
||||
if has_updates:
|
||||
keyboard.inline_keyboard.insert(-2, [
|
||||
InlineKeyboardButton(
|
||||
text="📋 Подробнее о версиях",
|
||||
callback_data="admin_updates_info"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки обновлений: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>\n\n"
|
||||
f"Не удалось связаться с сервером GitHub.\n"
|
||||
f"Попробуйте позже.\n\n"
|
||||
f"📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>",
|
||||
reply_markup=get_updates_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_version_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("📋 Загружаю информацию о версиях...")
|
||||
|
||||
try:
|
||||
version_info = await version_service.get_version_info()
|
||||
|
||||
current_version = version_info['current_version']
|
||||
current_release = version_info['current_release']
|
||||
newer_releases = version_info['newer_releases']
|
||||
has_updates = version_info['has_updates']
|
||||
last_check = version_info['last_check']
|
||||
repo_url = version_info['repo_url']
|
||||
|
||||
current_info = f"📦 <b>ТЕКУЩАЯ ВЕРСИЯ</b>\n\n"
|
||||
|
||||
if current_release:
|
||||
current_info += f"🏷️ <b>Версия:</b> <code>{current_release.tag_name}</code>\n"
|
||||
current_info += f"📅 <b>Дата релиза:</b> {current_release.formatted_date}\n"
|
||||
if current_release.short_description:
|
||||
current_info += f"📝 <b>Описание:</b>\n{current_release.short_description}\n"
|
||||
else:
|
||||
current_info += f"🏷️ <b>Версия:</b> <code>{current_version}</code>\n"
|
||||
current_info += f"ℹ️ <b>Статус:</b> Информация о релизе недоступна\n"
|
||||
|
||||
message_parts = [current_info]
|
||||
|
||||
if has_updates and newer_releases:
|
||||
updates_info = f"\n🆕 <b>ДОСТУПНЫЕ ОБНОВЛЕНИЯ</b>\n\n"
|
||||
|
||||
for i, release in enumerate(newer_releases):
|
||||
icon = "🔥" if i == 0 else "📦"
|
||||
if release.prerelease:
|
||||
icon = "🧪"
|
||||
elif release.is_dev:
|
||||
icon = "🔧"
|
||||
|
||||
updates_info += f"{icon} <b>{release.tag_name}</b>\n"
|
||||
updates_info += f" 📅 {release.formatted_date}\n"
|
||||
if release.short_description:
|
||||
updates_info += f" 📝 {release.short_description}\n"
|
||||
updates_info += "\n"
|
||||
|
||||
message_parts.append(updates_info.rstrip())
|
||||
|
||||
system_info = f"\n🔧 <b>СИСТЕМА ОБНОВЛЕНИЙ</b>\n\n"
|
||||
system_info += f"🔗 <b>Репозиторий:</b> {version_service.repo}\n"
|
||||
system_info += f"⚡ <b>Автопроверка:</b> {'Включена' if version_service.enabled else 'Отключена'}\n"
|
||||
system_info += f"🕐 <b>Интервал:</b> Каждый час\n"
|
||||
|
||||
if last_check:
|
||||
system_info += f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
|
||||
message_parts.append(system_info.rstrip())
|
||||
|
||||
final_message = "\n".join(message_parts)
|
||||
|
||||
if len(final_message) > 4000:
|
||||
final_message = final_message[:3900] + "\n\n... (информация обрезана)"
|
||||
|
||||
await callback.message.edit_text(
|
||||
final_message,
|
||||
reply_markup=get_version_info_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о версиях: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>ОШИБКА ЗАГРУЗКИ</b>\n\n"
|
||||
f"Не удалось получить информацию о версиях.\n\n"
|
||||
f"📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>",
|
||||
reply_markup=get_version_info_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_updates_menu,
|
||||
F.data == "admin_updates"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_updates,
|
||||
F.data == "admin_updates_check"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_version_info,
|
||||
F.data == "admin_updates_info"
|
||||
)
|
||||
@@ -0,0 +1,584 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.filters import StateFilter
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.user_message import (
|
||||
create_user_message, get_all_user_messages, get_user_message_by_id,
|
||||
update_user_message, delete_user_message, toggle_user_message_status,
|
||||
get_user_messages_stats
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserMessageStates(StatesGroup):
|
||||
waiting_for_message_text = State()
|
||||
waiting_for_edit_text = State()
|
||||
|
||||
|
||||
def get_user_messages_keyboard(language: str = "ru"):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📝 Добавить сообщение",
|
||||
callback_data="add_user_message"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📋 Список сообщений",
|
||||
callback_data="list_user_messages:0"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data="user_messages_stats"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в админку",
|
||||
callback_data="admin_panel"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_message_actions_keyboard(message_id: int, is_active: bool, language: str = "ru"):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
status_text = "🔴 Деактивировать" if is_active else "🟢 Активировать"
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Редактировать",
|
||||
callback_data=f"edit_user_message:{message_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=status_text,
|
||||
callback_data=f"toggle_user_message:{message_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=f"delete_user_message:{message_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔙 К списку",
|
||||
callback_data="list_user_messages:0"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_messages_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
text = (
|
||||
"📢 <b>Управление сообщениями в главном меню</b>\n\n"
|
||||
"Здесь вы можете добавлять сообщения, которые будут показываться пользователям "
|
||||
"в главном меню между информацией о подписке и кнопками действий.\n\n"
|
||||
"• Сообщения поддерживают HTML теги\n"
|
||||
"• Можно создать несколько сообщений\n"
|
||||
"• Активные сообщения показываются случайно\n"
|
||||
"• Неактивные сообщения не показываются"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def add_user_message_start(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.validators import get_html_help_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Добавление нового сообщения</b>\n\n"
|
||||
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
|
||||
f"{get_html_help_text()}\n\n"
|
||||
f"Отправьте /cancel для отмены.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(UserMessageStates.waiting_for_message_text)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_new_message_text(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if message.text == "/cancel":
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Добавление сообщения отменено.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
message_text = message.text.strip()
|
||||
|
||||
if len(message_text) > 4000:
|
||||
await message.answer(
|
||||
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
|
||||
"Попробуйте еще раз или отправьте /cancel для отмены."
|
||||
)
|
||||
return
|
||||
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
is_valid, error_msg = validate_html_tags(message_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
|
||||
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
|
||||
parse_mode=None
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
new_message = await create_user_message(
|
||||
db=db,
|
||||
message_text=message_text,
|
||||
created_by=db_user.id,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
f"✅ <b>Сообщение добавлено!</b>\n\n"
|
||||
f"<b>ID:</b> {new_message.id}\n"
|
||||
f"<b>Статус:</b> {'🟢 Активно' if new_message.is_active else '🔴 Неактивно'}\n"
|
||||
f"<b>Создано:</b> {new_message.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Предварительный просмотр:</b>\n"
|
||||
f"<blockquote>{message_text}</blockquote>",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания сообщения: {e}")
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при создании сообщения. Попробуйте еще раз.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_user_messages(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = 0
|
||||
if ":" in callback.data:
|
||||
try:
|
||||
page = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
page = 0
|
||||
|
||||
limit = 5
|
||||
offset = page * limit
|
||||
|
||||
messages = await get_all_user_messages(db, offset=offset, limit=limit)
|
||||
|
||||
if not messages:
|
||||
await callback.message.edit_text(
|
||||
"📋 <b>Список сообщений</b>\n\n"
|
||||
"Сообщений пока нет. Добавьте первое сообщение!",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = "📋 <b>Список сообщений</b>\n\n"
|
||||
|
||||
for msg in messages:
|
||||
status_emoji = "🟢" if msg.is_active else "🔴"
|
||||
preview = msg.message_text[:100] + "..." if len(msg.message_text) > 100 else msg.message_text
|
||||
preview = preview.replace('<', '<').replace('>', '>')
|
||||
|
||||
text += (
|
||||
f"{status_emoji} <b>ID {msg.id}</b>\n"
|
||||
f"{preview}\n"
|
||||
f"📅 {msg.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = []
|
||||
|
||||
for msg in messages:
|
||||
status_emoji = "🟢" if msg.is_active else "🔴"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} ID {msg.id}",
|
||||
callback_data=f"view_user_message:{msg.id}"
|
||||
)
|
||||
])
|
||||
|
||||
nav_buttons = []
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"list_user_messages:{page-1}"
|
||||
)
|
||||
)
|
||||
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Добавить",
|
||||
callback_data="add_user_message"
|
||||
)
|
||||
)
|
||||
|
||||
if len(messages) == limit:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=f"list_user_messages:{page+1}"
|
||||
)
|
||||
)
|
||||
|
||||
if nav_buttons:
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data="user_messages_panel"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_user_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
|
||||
|
||||
text = (
|
||||
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
|
||||
f"<b>Статус:</b> {status_text}\n"
|
||||
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Содержимое:</b>\n"
|
||||
f"<blockquote>{message.message_text}</blockquote>"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_message_actions_keyboard(
|
||||
message_id, message.is_active, db_user.language
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_message_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
message = await toggle_user_message_status(db, message_id)
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
status_text = "активировано" if message.is_active else "деактивировано"
|
||||
await callback.answer(f"✅ Сообщение {status_text}")
|
||||
|
||||
await view_user_message(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_message_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Подтвердить удаление сообщения"""
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
success = await delete_user_message(db, message_id)
|
||||
|
||||
if success:
|
||||
await callback.answer("✅ Сообщение удалено")
|
||||
await list_user_messages(
|
||||
types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data="list_user_messages:0",
|
||||
message=callback.message
|
||||
),
|
||||
db_user,
|
||||
db
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка удаления сообщения", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_user_messages_stats(db)
|
||||
|
||||
text = (
|
||||
"📊 <b>Статистика сообщений</b>\n\n"
|
||||
f"📝 Всего сообщений: <b>{stats['total_messages']}</b>\n"
|
||||
f"🟢 Активных: <b>{stats['active_messages']}</b>\n"
|
||||
f"🔴 Неактивных: <b>{stats['inactive_messages']}</b>\n\n"
|
||||
"Активные сообщения показываются пользователям случайным образом "
|
||||
"в главном меню между информацией о подписке и кнопками действий."
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data="user_messages_panel"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def edit_user_message_start(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
|
||||
f"<b>Текущий текст:</b>\n"
|
||||
f"<blockquote>{message.message_text}</blockquote>\n\n"
|
||||
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_data({"editing_message_id": message_id})
|
||||
await state.set_state(UserMessageStates.waiting_for_edit_text)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_message_text(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if message.text == "/cancel":
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Редактирование отменено.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
message_id = data.get("editing_message_id")
|
||||
|
||||
if not message_id:
|
||||
await state.clear()
|
||||
await message.answer("❌ Ошибка: ID сообщения не найден")
|
||||
return
|
||||
|
||||
new_text = message.text.strip()
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
|
||||
"Попробуйте еще раз или отправьте /cancel для отмены."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
updated_message = await update_user_message(
|
||||
db=db,
|
||||
message_id=message_id,
|
||||
message_text=new_text
|
||||
)
|
||||
|
||||
if updated_message:
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
f"✅ <b>Сообщение обновлено!</b>\n\n"
|
||||
f"<b>ID:</b> {updated_message.id}\n"
|
||||
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Новый текст:</b>\n"
|
||||
f"<blockquote>{new_text}</blockquote>",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Сообщение не найдено или ошибка обновления.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления сообщения: {e}")
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при обновлении сообщения.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_messages_panel,
|
||||
F.data == "user_messages_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
add_user_message_start,
|
||||
F.data == "add_user_message"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_new_message_text,
|
||||
StateFilter(UserMessageStates.waiting_for_message_text)
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
edit_user_message_start,
|
||||
F.data.startswith("edit_user_message:")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_edit_message_text,
|
||||
StateFilter(UserMessageStates.waiting_for_edit_text)
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
list_user_messages,
|
||||
F.data.startswith("list_user_messages")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
view_user_message,
|
||||
F.data.startswith("view_user_message:")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_message_status,
|
||||
F.data.startswith("toggle_user_message:")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_message_confirm,
|
||||
F.data.startswith("delete_user_message:")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_messages_stats,
|
||||
F.data == "user_messages_stats"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,792 @@
|
||||
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)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
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"📊 Курс: {int(settings.get_stars_rate())}₽ за звезду\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 start_cryptobot_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cryptobot_enabled():
|
||||
await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
from app.utils.currency_converter import currency_converter
|
||||
try:
|
||||
current_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
rate_text = f"💱 Текущий курс: 1 USD = {current_rate:.2f} ₽"
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось получить курс валют: {e}")
|
||||
current_rate = 95.0
|
||||
rate_text = f"💱 Курс: 1 USD ≈ {current_rate:.0f} ₽"
|
||||
|
||||
available_assets = settings.get_cryptobot_assets()
|
||||
assets_text = ", ".join(available_assets)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
|
||||
f"Введите сумму для пополнения в рублях от 100 до 100,000 ₽:\n\n"
|
||||
f"💰 Доступные активы: {assets_text}\n"
|
||||
f"⚡ Мгновенное зачисление на баланс\n"
|
||||
f"🔒 Безопасная оплата через CryptoBot\n\n"
|
||||
f"{rate_text}\n"
|
||||
f"Сумма будет автоматически конвертирована в USD для оплаты.",
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
|
||||
await callback.answer()
|
||||
|
||||
@error_handler
|
||||
async def process_cryptobot_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cryptobot_enabled():
|
||||
await message.answer("❌ Оплата криптовалютой временно недоступна")
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer("Минимальная сумма пополнения: 100 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
|
||||
return
|
||||
|
||||
try:
|
||||
# Получаем курс из состояния или запрашиваем заново
|
||||
data = await state.get_data()
|
||||
current_rate = data.get('current_rate')
|
||||
|
||||
if not current_rate:
|
||||
from app.utils.currency_converter import currency_converter
|
||||
current_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
|
||||
# Конвертируем рубли в доллары
|
||||
amount_usd = amount_rubles / current_rate
|
||||
|
||||
# Округляем до 2 знаков после запятой
|
||||
amount_usd = round(amount_usd, 2)
|
||||
|
||||
if amount_usd < 1:
|
||||
await message.answer("❌ Минимальная сумма для оплаты в USD: 1.00 USD")
|
||||
return
|
||||
|
||||
if amount_usd > 1000:
|
||||
await message.answer("❌ Максимальная сумма для оплаты в USD: 1,000 USD")
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
payment_result = await payment_service.create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=f"Пополнение баланса на {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
bot_invoice_url = payment_result.get("bot_invoice_url")
|
||||
mini_app_invoice_url = payment_result.get("mini_app_invoice_url")
|
||||
|
||||
payment_url = bot_invoice_url or mini_app_invoice_url
|
||||
|
||||
if not payment_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"🪙 <b>Оплата криптовалютой</b>\n\n"
|
||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n"
|
||||
f"💵 К оплате: {amount_usd:.2f} USD\n"
|
||||
f"🪙 Актив: {payment_result['asset']}\n"
|
||||
f"💱 Курс: 1 USD = {current_rate:.2f} ₽\n"
|
||||
f"🆔 ID платежа: {payment_result['invoice_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить'\n"
|
||||
f"2. Выберите удобный актив\n"
|
||||
f"3. Переведите указанную сумму\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
|
||||
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result['invoice_id']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
|
||||
@error_handler
|
||||
async def check_cryptobot_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.cryptobot import get_cryptobot_payment_by_id
|
||||
payment = await get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = {
|
||||
"active": "⏳",
|
||||
"paid": "✅",
|
||||
"expired": "❌"
|
||||
}
|
||||
|
||||
status_text = {
|
||||
"active": "Ожидает оплаты",
|
||||
"paid": "Оплачен",
|
||||
"expired": "Истек"
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(payment.status, "❓")
|
||||
status = status_text.get(payment.status, "Неизвестно")
|
||||
|
||||
message_text = (f"🪙 Статус платежа:\n\n"
|
||||
f"🆔 ID: {payment.invoice_id[:8]}...\n"
|
||||
f"💰 Сумма: {payment.amount} {payment.asset}\n"
|
||||
f"📊 Статус: {emoji} {status}\n"
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
|
||||
|
||||
if payment.is_paid:
|
||||
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_expired:
|
||||
message_text += f"\n❌ Платеж истек. Обратитесь в {settings.SUPPORT_USERNAME}"
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса CryptoBot платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_cryptobot_payment,
|
||||
F.data == "topup_cryptobot"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_cryptobot_payment_status,
|
||||
F.data.startswith("check_cryptobot_")
|
||||
)
|
||||
@@ -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"])
|
||||
)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
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
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
|
||||
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 = await get_main_menu_text(db_user, texts, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
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()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
has_active_subscription = db_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if db_user.subscription:
|
||||
subscription_is_active = db_user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
return "❌ Отсутствует"
|
||||
|
||||
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⚠️ истекает сегодня!"
|
||||
|
||||
async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
if "Выберите действие:" in base_text:
|
||||
parts = base_text.split("Выберите действие:")
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
|
||||
|
||||
if "Выберите действие:" in base_text:
|
||||
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
|
||||
else:
|
||||
return f"{base_text}\n\n{random_message}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
return base_text
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_back_to_menu,
|
||||
F.data == "back_to_menu"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_service_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,324 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import qrcode
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_referral_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.user_utils import (
|
||||
get_detailed_referral_list,
|
||||
get_referral_analytics,
|
||||
get_user_referral_summary,
|
||||
)
|
||||
|
||||
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 += "📢 Приглашайте друзей и зарабатывайте!"
|
||||
|
||||
if callback.message.text:
|
||||
await callback.message.edit_text(
|
||||
referral_text,
|
||||
reply_markup=get_referral_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
referral_text,
|
||||
reply_markup=get_referral_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_referral_qr(
|
||||
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}"
|
||||
|
||||
qr_dir = Path("data") / "referral_qr"
|
||||
qr_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = qr_dir / f"{db_user.id}.png"
|
||||
if not file_path.exists():
|
||||
img = qrcode.make(referral_link)
|
||||
img.save(file_path)
|
||||
|
||||
photo = FSInputFile(file_path)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_media(
|
||||
types.InputMediaPhoto(
|
||||
media=photo,
|
||||
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
photo,
|
||||
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
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_referral_qr,
|
||||
F.data == "referral_show_qr"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_detailed_referral_list,
|
||||
F.data == "referral_list"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_referral_analytics,
|
||||
F.data == "referral_analytics"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
lambda callback, db_user, db: show_detailed_referral_list(
|
||||
callback, db_user, db, int(callback.data.split('_')[-1])
|
||||
),
|
||||
F.data.startswith("referral_list_page_")
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Ошибка валидации платежа. Попробуйте еще раз."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
if not user:
|
||||
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Пользователь не найден. Обратитесь в поддержку."
|
||||
)
|
||||
return
|
||||
break
|
||||
except Exception as db_error:
|
||||
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
)
|
||||
return
|
||||
|
||||
await query.answer(ok=True)
|
||||
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
)
|
||||
|
||||
|
||||
async def handle_successful_payment(
|
||||
message: types.Message,
|
||||
db: AsyncSession,
|
||||
**kwargs
|
||||
):
|
||||
try:
|
||||
payment = message.successful_payment
|
||||
user_id = message.from_user.id
|
||||
|
||||
logger.info(
|
||||
f"💳 Успешный Stars платеж от {user_id}: "
|
||||
f"{payment.total_amount} XTR, "
|
||||
f"payload: {payment.invoice_payload}, "
|
||||
f"charge_id: {payment.telegram_payment_charge_id}"
|
||||
)
|
||||
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
|
||||
await message.answer(
|
||||
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку."
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
success = await payment_service.process_stars_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
stars_amount=payment.total_amount,
|
||||
payload=payment.invoice_payload,
|
||||
telegram_payment_charge_id=payment.telegram_payment_charge_id
|
||||
)
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
|
||||
await message.answer(
|
||||
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
f"⭐ Потрачено звезд: {payment.total_amount}\n"
|
||||
f"💰 Зачислено на баланс: {int(rubles_amount)} ₽\n"
|
||||
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Спасибо за пополнение! 🚀",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}₽"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при зачислении средств. "
|
||||
"Обратитесь в поддержку, платеж будет проверен вручную."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
|
||||
await message.answer(
|
||||
"❌ Техническая ошибка при обработке платежа. "
|
||||
"Обратитесь в поддержку для решения проблемы."
|
||||
)
|
||||
|
||||
|
||||
def register_stars_handlers(dp: Dispatcher):
|
||||
|
||||
dp.pre_checkout_query.register(
|
||||
handle_pre_checkout_query,
|
||||
F.currency == "XTR"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
handle_successful_payment,
|
||||
F.successful_payment
|
||||
)
|
||||
|
||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||
@@ -0,0 +1,809 @@
|
||||
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, get_post_registration_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
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
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await callback.message.answer(f"Добро пожаловать, {existing_user.full_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()
|
||||
|
||||
user_name = callback.from_user.first_name or callback.from_user.username or "друг"
|
||||
|
||||
offer_text = (
|
||||
f"<b>Привет, {user.full_name}!</b>\n\n"
|
||||
f"🎁 <b>{settings.TRIAL_DURATION_DAYS} дней VPN бесплатно!</b>\n"
|
||||
"Подключайтесь за минуту и забудьте о блокировках.\n\n"
|
||||
"✅ До <b>1 Гбит/с</b> скорость\n"
|
||||
"✅ Умный VPN — можно не отключать для большинства российских сервисов\n"
|
||||
"✅ Современные протоколы — максимум защиты и анонимности\n\n"
|
||||
f"👉 Всего <b>{int(settings.PRICE_30_DAYS/100)}₽/мес за 1 устройство</b>\n\n"
|
||||
"👇 Жмите кнопку и подключайтесь!"
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке предложения триала: {e}")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
|
||||
|
||||
async def complete_registration(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
|
||||
|
||||
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
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await message.answer(f"Добро пожаловать, {existing_user.full_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()
|
||||
|
||||
user_name = message.from_user.first_name or message.from_user.username or "друг"
|
||||
offer_text = (
|
||||
f"Привет, {user_name}!\n\n"
|
||||
f"Подключите VPN бесплатно! Дарим вам {settings.TRIAL_DURATION_DAYS} дней!\n\n"
|
||||
"Наши преимущества:\n"
|
||||
" • Высокая скорость соединения — до 1гб/с\n"
|
||||
" • Умный VPN — можно не отключать для большинства российских сервисов\n"
|
||||
" • Самые современные протоколы — высокая защита от блокировки\n\n"
|
||||
"Стоимость 100₽/мес за 1 устройство\n\n"
|
||||
"👇Жмите на кнопку👇 чтобы подключить в 1 клик"
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке предложения триала: {e}")
|
||||
|
||||
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"
|
||||
)]
|
||||
])
|
||||
|
||||
async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
if "Выберите действие:" in base_text:
|
||||
parts = base_text.split("Выберите действие:")
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
|
||||
|
||||
if "Выберите действие:" in base_text:
|
||||
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
|
||||
else:
|
||||
return f"{base_text}\n\n{random_message}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
return base_text
|
||||
|
||||
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
)
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
if "Выберите действие:" in base_text:
|
||||
parts = base_text.split("Выберите действие:")
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
|
||||
|
||||
if "Выберите действие:" in base_text:
|
||||
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
|
||||
else:
|
||||
return f"{base_text}\n\n{random_message}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
return base_text
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
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
@@ -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"
|
||||
)
|
||||
@@ -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('trbt-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="Ошибка обработки платежа")
|
||||
@@ -0,0 +1,662 @@
|
||||
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="📢 Сообщения в меню", callback_data="user_messages_panel"),
|
||||
InlineKeyboardButton(text="🔄 Обновления", callback_data="admin_updates")
|
||||
],
|
||||
[
|
||||
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,
|
||||
is_maintenance_active: bool,
|
||||
is_monitoring_active: bool,
|
||||
panel_has_issues: bool = False
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if is_maintenance_active:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🟢 Выключить техработы",
|
||||
callback_data="maintenance_toggle"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Включить техработы",
|
||||
callback_data="maintenance_toggle"
|
||||
)
|
||||
])
|
||||
|
||||
if is_monitoring_active:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⏹️ Остановить мониторинг",
|
||||
callback_data="maintenance_monitoring"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="▶️ Запустить мониторинг",
|
||||
callback_data="maintenance_monitoring"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔍 Проверить API",
|
||||
callback_data="maintenance_check_api"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Статус панели" + ("⚠️" if panel_has_issues else ""),
|
||||
callback_data="maintenance_check_panel"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="📢 Отправить уведомление",
|
||||
callback_data="maintenance_manual_notify"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить",
|
||||
callback_data="maintenance_panel"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="◀️ Назад",
|
||||
callback_data="admin_panel"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
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
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,517 @@
|
||||
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"{int(kopeks / 100)} ₽"
|
||||
|
||||
@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 ₽"
|
||||
|
||||
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
|
||||
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
|
||||
SUBSCRIPTION_ACTIVE = "✅ Активна"
|
||||
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
|
||||
|
||||
SUBSCRIPTION_INFO = """
|
||||
📱 <b>Информация о подписке</b>
|
||||
|
||||
📊 <b>Статус:</b> {status}
|
||||
🎭 <b>Тип:</b> {type}
|
||||
📅 <b>Действует до:</b> {end_date}
|
||||
⏰ <b>Осталось дней:</b> {days_left}
|
||||
|
||||
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
|
||||
🌍 <b>Серверы:</b> {countries_count} стран
|
||||
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
"""
|
||||
|
||||
TRIAL_AVAILABLE = """
|
||||
🎁 <b>Тестовая подписка</b>
|
||||
|
||||
Вы можете получить бесплатную тестовую подписку:
|
||||
|
||||
⏰ <b>Период:</b> {days} дней
|
||||
📈 <b>Трафик:</b> {traffic} ГБ
|
||||
📱 <b>Устройства:</b> {devices} шт.
|
||||
🌍 <b>Сервер:</b> 1 страна
|
||||
|
||||
Активировать тестовую подписку?
|
||||
"""
|
||||
|
||||
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
|
||||
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
|
||||
|
||||
BUY_SUBSCRIPTION_START = """
|
||||
💎 <b>Настройка подписки</b>
|
||||
|
||||
Давайте настроим вашу подписку под ваши потребности.
|
||||
|
||||
Сначала выберите период подписки:
|
||||
"""
|
||||
|
||||
SELECT_PERIOD = "Выберите период:"
|
||||
SELECT_TRAFFIC = "Выберите пакет трафика:"
|
||||
SELECT_COUNTRIES = "Выберите страны:"
|
||||
SELECT_DEVICES = "Количество устройств:"
|
||||
|
||||
PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"
|
||||
PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"
|
||||
PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"
|
||||
PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}"
|
||||
PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}"
|
||||
PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}"
|
||||
|
||||
TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"
|
||||
TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"
|
||||
TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"
|
||||
TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"
|
||||
TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"
|
||||
TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}"
|
||||
TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}"
|
||||
|
||||
SUBSCRIPTION_SUMMARY = """
|
||||
📋 <b>Итоговая конфигурация</b>
|
||||
|
||||
📅 <b>Период:</b> {period} дней
|
||||
📈 <b>Трафик:</b> {traffic}
|
||||
🌍 <b>Страны:</b> {countries}
|
||||
📱 <b>Устройства:</b> {devices}
|
||||
|
||||
💰 <b>Итого к оплате:</b> {total_price}
|
||||
|
||||
Подтвердить покупку?
|
||||
"""
|
||||
|
||||
INSUFFICIENT_BALANCE = """❌ Недостаточно средств на балансе.
|
||||
|
||||
<b>Пополните баланс на {amount} и попробуйте снова.</b>
|
||||
"""
|
||||
GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса"
|
||||
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
|
||||
|
||||
BALANCE_INFO = """
|
||||
💰 <b>Баланс: {balance}</b>
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
BALANCE_HISTORY = "📊 История операций"
|
||||
BALANCE_TOP_UP = "💳 Пополнить"
|
||||
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
|
||||
|
||||
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
|
||||
TOP_UP_METHODS = """
|
||||
💳 <b>Выберите способ оплаты</b>
|
||||
|
||||
Сумма: {amount}
|
||||
"""
|
||||
|
||||
TOP_UP_STARS = "⭐ Telegram Stars"
|
||||
TOP_UP_TRIBUTE = "💎 Банковская карта"
|
||||
|
||||
PROMOCODE_ENTER = "🎫 Введите промокод:"
|
||||
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
|
||||
PROMOCODE_INVALID = "❌ Неверный промокод"
|
||||
PROMOCODE_EXPIRED = "❌ Промокод истек"
|
||||
PROMOCODE_USED = "❌ Промокод уже использован"
|
||||
|
||||
REFERRAL_INFO = """
|
||||
🤝 <b>Реферальная программа</b>
|
||||
|
||||
👥 <b>Приглашено:</b> {referrals_count} друзей
|
||||
💰 <b>Заработано:</b> {earned_amount}
|
||||
|
||||
🔗 <b>Ваша реферальная ссылка:</b>
|
||||
<code>{referral_link}</code>
|
||||
|
||||
🎫 <b>Ваш промокод:</b>
|
||||
<code>{referral_code}</code>
|
||||
|
||||
💰 <b>Условия:</b>
|
||||
• За каждого друга: {registration_bonus}
|
||||
• Процент с пополнений: {commission_percent}%
|
||||
"""
|
||||
|
||||
REFERRAL_INVITE_MESSAGE = """
|
||||
🎯 <b>Приглашение в VPN сервис</b>
|
||||
|
||||
Привет! Приглашаю тебя в отличный VPN сервис!
|
||||
|
||||
🎁 По моей ссылке ты получишь бонус: {bonus}
|
||||
|
||||
🔗 Переходи: {link}
|
||||
🎫 Или используй промокод: {code}
|
||||
|
||||
💪 Быстро, надежно, недорого!
|
||||
"""
|
||||
|
||||
CREATE_INVITE = "📝 Создать приглашение"
|
||||
|
||||
TRIAL_ENDING_SOON = """
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через несколько часов.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {price}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Скорость до 1ГБит/сек
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_ACTIVE = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
|
||||
|
||||
⏰ Ориентировочное время завершения: неизвестно
|
||||
🔄 Попробуйте позже
|
||||
|
||||
Приносим извинения за временные неудобства.
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_API_ERROR = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен из-за проблем с подключением к серверам.
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {last_check}
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRING_PAID = """
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {end_date}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
|
||||
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
|
||||
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
|
||||
Новая дата окончания: {new_end_date}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
|
||||
💰 Ваш баланс: {balance}
|
||||
💳 Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
SUPPORT_INFO = f"""
|
||||
🛠️ <b>Техническая поддержка</b>
|
||||
|
||||
По всем вопросам обращайтесь к нашей поддержке:
|
||||
|
||||
👤 {settings.SUPPORT_USERNAME}
|
||||
|
||||
Мы поможем с:
|
||||
• Настройкой подключения
|
||||
• Решением технических проблем
|
||||
• Вопросами по оплате
|
||||
• Другими вопросами
|
||||
|
||||
⏰ Время ответа: обычно в течение 1-2 часов
|
||||
"""
|
||||
|
||||
CONTACT_SUPPORT = "💬 Написать в поддержку"
|
||||
|
||||
ADMIN_PANEL = """
|
||||
⚙️ <b>Административная панель</b>
|
||||
|
||||
Выберите раздел для управления:
|
||||
"""
|
||||
|
||||
ADMIN_USERS = "👥 Пользователи"
|
||||
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
|
||||
ADMIN_PROMOCODES = "🎫 Промокоды"
|
||||
ADMIN_MESSAGES = "📨 Рассылки"
|
||||
ADMIN_MONITORING = "🔍 Мониторинг"
|
||||
ADMIN_REFERRALS = "🤝 Рефералы"
|
||||
ADMIN_RULES = "📋 Правила"
|
||||
ADMIN_REMNAWAVE = "🖥️ Remnawave"
|
||||
ADMIN_STATISTICS = "📊 Статистика"
|
||||
|
||||
ACCESS_DENIED = "❌ Доступ запрещен"
|
||||
USER_NOT_FOUND = "❌ Пользователь не найден"
|
||||
SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена"
|
||||
INVALID_AMOUNT = "❌ Неверная сумма"
|
||||
OPERATION_CANCELLED = "❌ Операция отменена"
|
||||
|
||||
SUBSCRIPTION_EXPIRING = """
|
||||
⚠️ <b>Подписка истекает!</b>
|
||||
|
||||
Ваша подписка истекает через {days} дней.
|
||||
|
||||
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
Недостаточно средств на балансе: {balance}
|
||||
Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
|
||||
class EnglishTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("en")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Welcome to VPN Service!</b>
|
||||
|
||||
Our service provides fast and secure internet access without restrictions.
|
||||
|
||||
🔐 <b>Advantages:</b>
|
||||
• High connection speed
|
||||
• Servers in different countries
|
||||
• Reliable data protection
|
||||
• 24/7 support
|
||||
|
||||
To get started, select interface language:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
|
||||
|
||||
BACK = "⬅️ Back"
|
||||
CANCEL = "❌ Cancel"
|
||||
CONFIRM = "✅ Confirm"
|
||||
CONTINUE = "➡️ Continue"
|
||||
YES = "✅ Yes"
|
||||
NO = "❌ No"
|
||||
|
||||
MENU_BALANCE = "💰 Balance"
|
||||
MENU_SUBSCRIPTION = "📱 Subscription"
|
||||
MENU_TRIAL = "🎁 Trial subscription"
|
||||
INSUFFICIENT_BALANCE = """❌ Insufficient balance. " \
|
||||
|
||||
Top up {amount} and try again."""
|
||||
GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up"
|
||||
|
||||
|
||||
LANGUAGES = {
|
||||
"ru": RussianTexts,
|
||||
"en": EnglishTexts
|
||||
}
|
||||
|
||||
|
||||
def get_texts(language: str = "ru") -> Texts:
|
||||
return LANGUAGES.get(language, RussianTexts)()
|
||||
|
||||
async def get_rules_from_db(language: str = "ru") -> str:
|
||||
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("✅ Кеш правил очищен")
|
||||
@@ -0,0 +1,173 @@
|
||||
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.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
is_registration_process = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (current_state in registration_states)
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
)
|
||||
)
|
||||
|
||||
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.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
is_start_or_registration = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (current_state in registration_states)
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Сервисы бизнес-логики
|
||||
"""
|
||||
@@ -0,0 +1,710 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, Subscription, Transaction
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminNotificationService:
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
try:
|
||||
referrer = await get_user_by_id(db, referred_by_id)
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
else:
|
||||
return f"ID {referrer.telegram_id}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
|
||||
return f"ID {referred_by_id}"
|
||||
|
||||
async def send_trial_activation_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о триале: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_purchase_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
period_days: int,
|
||||
was_trial_conversion: bool = False
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
|
||||
|
||||
if was_trial_conversion:
|
||||
user_status = "🎯 Конверсия из триала"
|
||||
elif user.has_had_paid_subscription:
|
||||
user_status = "🔄 Продление/Обновление"
|
||||
else:
|
||||
user_status = "🆕 Первая покупка"
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
|
||||
📱 <b>Параметры подписки:</b>
|
||||
📅 Период: {period_days} дней
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
|
||||
return False
|
||||
|
||||
async def send_version_update_notification(
|
||||
self,
|
||||
current_version: str,
|
||||
latest_version,
|
||||
total_updates: int
|
||||
) -> bool:
|
||||
"""Отправляет уведомление о новых обновлениях"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
if latest_version.prerelease:
|
||||
update_type = "🧪 ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ"
|
||||
type_icon = "🧪"
|
||||
elif latest_version.is_dev:
|
||||
update_type = "🔧 DEV ВЕРСИЯ"
|
||||
type_icon = "🔧"
|
||||
else:
|
||||
update_type = "📦 НОВАЯ ВЕРСИЯ"
|
||||
type_icon = "📦"
|
||||
|
||||
description = latest_version.short_description
|
||||
if len(description) > 200:
|
||||
description = description[:197] + "..."
|
||||
|
||||
message = f"""{type_icon} <b>{update_type} ДОСТУПНА</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{current_version}</code>
|
||||
🆕 <b>Новая версия:</b> <code>{latest_version.tag_name}</code>
|
||||
📅 <b>Дата релиза:</b> {latest_version.formatted_date}
|
||||
|
||||
📝 <b>Описание:</b>
|
||||
{description}
|
||||
|
||||
🔢 <b>Всего доступно обновлений:</b> {total_updates}
|
||||
🔗 <b>Репозиторий:</b> https://github.com/{getattr(self, 'repo', 'fr1ngg/remnawave-bedolaga-telegram-bot')}
|
||||
|
||||
ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
|
||||
|
||||
⚙️ <i>Автоматическая проверка обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об обновлении: {e}")
|
||||
return False
|
||||
|
||||
async def send_version_check_error_notification(
|
||||
self,
|
||||
error_message: str,
|
||||
current_version: str
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
message = f"""⚠️ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{current_version}</code>
|
||||
❌ <b>Ошибка:</b> {error_message}
|
||||
|
||||
🔄 Следующая попытка через час.
|
||||
⚙️ Проверьте доступность GitHub API и настройки сети.
|
||||
|
||||
⚙️ <i>Система автоматических обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об ошибке проверки версий: {e}")
|
||||
return False
|
||||
|
||||
async def send_balance_topup_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
transaction: Transaction,
|
||||
old_balance: int
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение"
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
💰 <b>Детали пополнения:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
|
||||
💰 <b>Баланс:</b>
|
||||
📉 Было: {settings.format_price(old_balance)}
|
||||
📈 Стало: {settings.format_price(user.balance_kopeks)}
|
||||
➕ Изменение: +{settings.format_price(balance_change)}
|
||||
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_extension_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
extended_days: int,
|
||||
old_end_date: datetime
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
return False
|
||||
|
||||
async def _send_message(self, text: str) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
|
||||
return False
|
||||
|
||||
try:
|
||||
message_kwargs = {
|
||||
'chat_id': self.chat_id,
|
||||
'text': text,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
|
||||
if self.topic_id:
|
||||
message_kwargs['message_thread_id'] = self.topic_id
|
||||
|
||||
await self.bot.send_message(**message_kwargs)
|
||||
logger.info(f"Уведомление отправлено в чат {self.chat_id}")
|
||||
return True
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.error(f"Бот не имеет прав для отправки в чат {self.chat_id}")
|
||||
return False
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(f"Ошибка отправки уведомления: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке уведомления: {e}")
|
||||
return False
|
||||
|
||||
def _is_enabled(self) -> bool:
|
||||
return self.enabled and bool(self.chat_id)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: Optional[str]) -> str:
|
||||
method_names = {
|
||||
'telegram_stars': '⭐ Telegram Stars',
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
}
|
||||
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
|
||||
return method_names.get(payment_method, f'💰 С баланса')
|
||||
|
||||
def _format_traffic(self, traffic_gb: int) -> str:
|
||||
if traffic_gb == 0:
|
||||
return "∞ Безлимит"
|
||||
return f"{traffic_gb} ГБ"
|
||||
|
||||
def _get_subscription_status(self, user: User) -> str:
|
||||
if not user.subscription:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
sub = user.subscription
|
||||
if sub.is_trial:
|
||||
return f"🎯 Триал (до {sub.end_date.strftime('%d.%m')})"
|
||||
elif sub.is_active:
|
||||
return f"✅ Активна (до {sub.end_date.strftime('%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
async def _get_servers_info(self, squad_uuids: list) -> str:
|
||||
if not squad_uuids:
|
||||
return "❌ Нет серверов"
|
||||
|
||||
try:
|
||||
from app.handlers.subscription import get_servers_display_names
|
||||
servers_names = await get_servers_display_names(squad_uuids)
|
||||
return f"{len(squad_uuids)} шт. ({servers_names})"
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось получить названия серверов: {e}")
|
||||
return f"{len(squad_uuids)} шт."
|
||||
|
||||
|
||||
async def send_maintenance_status_notification(
|
||||
self,
|
||||
event_type: str,
|
||||
status: str,
|
||||
details: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
details = details or {}
|
||||
|
||||
if event_type == "enable":
|
||||
if details.get("auto_enabled", False):
|
||||
icon = "⚠️"
|
||||
title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "warning"
|
||||
else:
|
||||
icon = "🔧"
|
||||
title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "info"
|
||||
|
||||
elif event_type == "disable":
|
||||
icon = "✅"
|
||||
title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "success"
|
||||
|
||||
elif event_type == "api_status":
|
||||
if status == "online":
|
||||
icon = "🟢"
|
||||
title = "API REMNAWAVE ВОССТАНОВЛЕНО"
|
||||
alert_type = "success"
|
||||
else:
|
||||
icon = "🔴"
|
||||
title = "API REMNAWAVE НЕДОСТУПНО"
|
||||
alert_type = "error"
|
||||
|
||||
elif event_type == "monitoring":
|
||||
if status == "started":
|
||||
icon = "🔍"
|
||||
title = "МОНИТОРИНГ ЗАПУЩЕН"
|
||||
alert_type = "info"
|
||||
else:
|
||||
icon = "⏹️"
|
||||
title = "МОНИТОРИНГ ОСТАНОВЛЕН"
|
||||
alert_type = "info"
|
||||
else:
|
||||
icon = "ℹ️"
|
||||
title = "СИСТЕМА ТЕХРАБОТ"
|
||||
alert_type = "info"
|
||||
|
||||
message_parts = [f"{icon} <b>{title}</b>", ""]
|
||||
|
||||
if event_type == "enable":
|
||||
if details.get("reason"):
|
||||
message_parts.append(f"📋 <b>Причина:</b> {details['reason']}")
|
||||
|
||||
if details.get("enabled_at"):
|
||||
enabled_at = details["enabled_at"]
|
||||
if isinstance(enabled_at, str):
|
||||
from datetime import datetime
|
||||
enabled_at = datetime.fromisoformat(enabled_at)
|
||||
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
|
||||
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
|
||||
message_parts.append("")
|
||||
message_parts.append("❗ Обычные пользователи временно не могут использовать бота.")
|
||||
|
||||
elif event_type == "disable":
|
||||
if details.get("disabled_at"):
|
||||
disabled_at = details["disabled_at"]
|
||||
if isinstance(disabled_at, str):
|
||||
from datetime import datetime
|
||||
disabled_at = datetime.fromisoformat(disabled_at)
|
||||
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
|
||||
if details.get("duration"):
|
||||
duration = details["duration"]
|
||||
if isinstance(duration, (int, float)):
|
||||
hours = int(duration // 3600)
|
||||
minutes = int((duration % 3600) // 60)
|
||||
if hours > 0:
|
||||
duration_str = f"{hours}ч {minutes}мин"
|
||||
else:
|
||||
duration_str = f"{minutes}мин"
|
||||
message_parts.append(f"⏱️ <b>Длительность:</b> {duration_str}")
|
||||
|
||||
message_parts.append(f"🤖 <b>Было автоматическим:</b> {'Да' if details.get('was_auto', False) else 'Нет'}")
|
||||
message_parts.append("")
|
||||
message_parts.append("✅ Сервис снова доступен для пользователей.")
|
||||
|
||||
elif event_type == "api_status":
|
||||
message_parts.append(f"🔗 <b>API URL:</b> {details.get('api_url', 'неизвестно')}")
|
||||
|
||||
if status == "online":
|
||||
if details.get("response_time"):
|
||||
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
|
||||
|
||||
if details.get("consecutive_failures", 0) > 0:
|
||||
message_parts.append(f"🔄 <b>Неудачных попыток было:</b> {details['consecutive_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("API снова отвечает на запросы.")
|
||||
|
||||
else:
|
||||
if details.get("consecutive_failures"):
|
||||
message_parts.append(f"🔄 <b>Попытка №:</b> {details['consecutive_failures']}")
|
||||
|
||||
if details.get("error"):
|
||||
error_msg = str(details["error"])[:100]
|
||||
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("⚠️ Началась серия неудачных проверок API.")
|
||||
|
||||
elif event_type == "monitoring":
|
||||
if status == "started":
|
||||
if details.get("check_interval"):
|
||||
message_parts.append(f"🔄 <b>Интервал проверки:</b> {details['check_interval']} сек")
|
||||
|
||||
if details.get("auto_enable_configured") is not None:
|
||||
auto_enable = "Включено" if details["auto_enable_configured"] else "Отключено"
|
||||
message_parts.append(f"🤖 <b>Автовключение:</b> {auto_enable}")
|
||||
|
||||
if details.get("max_failures"):
|
||||
message_parts.append(f"🎯 <b>Порог ошибок:</b> {details['max_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Система будет следить за доступностью API.")
|
||||
|
||||
else:
|
||||
message_parts.append("Автоматический мониторинг API остановлен.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о техработах: {e}")
|
||||
return False
|
||||
|
||||
async def send_remnawave_panel_status_notification(
|
||||
self,
|
||||
status: str,
|
||||
details: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
details = details or {}
|
||||
|
||||
status_config = {
|
||||
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
|
||||
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
|
||||
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
|
||||
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
|
||||
}
|
||||
|
||||
config = status_config.get(status, status_config["offline"])
|
||||
|
||||
message_parts = [
|
||||
f"{config['icon']} <b>{config['title']}</b>",
|
||||
""
|
||||
]
|
||||
|
||||
if details.get("api_url"):
|
||||
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
|
||||
|
||||
if details.get("response_time"):
|
||||
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
|
||||
|
||||
if details.get("last_check"):
|
||||
last_check = details["last_check"]
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
|
||||
|
||||
if details.get("users_online"):
|
||||
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("✅ Все системы работают нормально.")
|
||||
|
||||
elif status == "offline":
|
||||
if details.get("error"):
|
||||
error_msg = str(details["error"])[:150]
|
||||
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
|
||||
|
||||
if details.get("consecutive_failures"):
|
||||
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
|
||||
|
||||
elif status == "degraded":
|
||||
if details.get("issues"):
|
||||
issues = details["issues"]
|
||||
if isinstance(issues, list):
|
||||
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
|
||||
for issue in issues[:3]:
|
||||
message_parts.append(f" • {issue}")
|
||||
else:
|
||||
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель работает, но возможны задержки или сбои.")
|
||||
|
||||
elif status == "maintenance":
|
||||
if details.get("maintenance_reason"):
|
||||
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
|
||||
|
||||
if details.get("estimated_duration"):
|
||||
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_remnawave_panel_status_notification(
|
||||
self,
|
||||
status: str,
|
||||
details: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
details = details or {}
|
||||
|
||||
status_config = {
|
||||
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
|
||||
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
|
||||
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
|
||||
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
|
||||
}
|
||||
|
||||
config = status_config.get(status, status_config["offline"])
|
||||
|
||||
message_parts = [
|
||||
f"{config['icon']} <b>{config['title']}</b>",
|
||||
""
|
||||
]
|
||||
|
||||
if details.get("api_url"):
|
||||
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
|
||||
|
||||
if details.get("response_time"):
|
||||
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
|
||||
|
||||
if details.get("last_check"):
|
||||
last_check = details["last_check"]
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
|
||||
|
||||
if details.get("users_online"):
|
||||
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("✅ Все системы работают нормально.")
|
||||
|
||||
elif status == "offline":
|
||||
if details.get("error"):
|
||||
error_msg = str(details["error"])[:150]
|
||||
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
|
||||
|
||||
if details.get("consecutive_failures"):
|
||||
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
|
||||
|
||||
elif status == "degraded":
|
||||
if details.get("issues"):
|
||||
issues = details["issues"]
|
||||
if isinstance(issues, list):
|
||||
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
|
||||
for issue in issues[:3]:
|
||||
message_parts.append(f" • {issue}")
|
||||
else:
|
||||
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель работает, но возможны задержки или сбои.")
|
||||
|
||||
elif status == "maintenance":
|
||||
if details.get("maintenance_reason"):
|
||||
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
|
||||
|
||||
if details.get("estimated_duration"):
|
||||
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
|
||||
|
||||
if details.get("manual_message"):
|
||||
message_parts.append(f"💬 <b>Сообщение:</b> {details['manual_message']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
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 _send_admin_notification(self, message: str, alert_type: str = "info"):
|
||||
if not self._bot:
|
||||
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
|
||||
return False
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self._bot)
|
||||
|
||||
if not notification_service._is_enabled():
|
||||
logger.debug("Уведомления администраторов отключены")
|
||||
return False
|
||||
|
||||
emoji_map = {
|
||||
"error": "🚨",
|
||||
"warning": "⚠️",
|
||||
"success": "✅",
|
||||
"info": "ℹ️"
|
||||
}
|
||||
emoji = emoji_map.get(alert_type, "ℹ️")
|
||||
|
||||
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
|
||||
return await notification_service._send_message(formatted_message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления через AdminNotificationService: {e}")
|
||||
return False
|
||||
|
||||
async def _notify_admins(self, message: str, alert_type: str = "info"):
|
||||
if not self._bot:
|
||||
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
|
||||
return
|
||||
|
||||
notification_sent = await self._send_admin_notification(message, alert_type)
|
||||
|
||||
if notification_sent:
|
||||
logger.info("Уведомление успешно отправлено через AdminNotificationService")
|
||||
return
|
||||
|
||||
logger.info("Отправляем уведомление напрямую администраторам")
|
||||
|
||||
cache_key = f"maintenance_notification_{alert_type}"
|
||||
if await cache.get(cache_key):
|
||||
return
|
||||
|
||||
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:
|
||||
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
|
||||
}
|
||||
|
||||
async def send_remnawave_status_notification(self, status: str, details: str = "") -> bool:
|
||||
try:
|
||||
status_emojis = {
|
||||
"online": "🟢",
|
||||
"offline": "🔴",
|
||||
"warning": "🟡",
|
||||
"error": "⚠️"
|
||||
}
|
||||
|
||||
emoji = status_emojis.get(status, "ℹ️")
|
||||
|
||||
message = f"""Статус панели Remnawave изменился
|
||||
|
||||
{emoji} <b>Статус:</b> {status.upper()}
|
||||
🔗 <b>URL:</b> {settings.REMNAWAVE_API_URL}
|
||||
{details}"""
|
||||
|
||||
alert_type = "error" if status in ["offline", "error"] else "info"
|
||||
await self._notify_admins(message, alert_type)
|
||||
|
||||
logger.info(f"Отправлено уведомление о статусе Remnawave: {status}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе Remnawave: {e}")
|
||||
return False
|
||||
|
||||
|
||||
maintenance_service = MaintenanceService()
|
||||
@@ -0,0 +1,748 @@
|
||||
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.crud.notification import (
|
||||
notification_sent,
|
||||
record_notification,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.payment_service import PaymentService
|
||||
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
|
||||
|
||||
user_key = f"user_{user.telegram_id}_today"
|
||||
|
||||
if (await notification_sent(db, user.id, subscription.id, "expiring", days) or
|
||||
user_key in all_processed_users):
|
||||
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
|
||||
continue
|
||||
|
||||
should_send = True
|
||||
for other_days in warning_days:
|
||||
if other_days < days:
|
||||
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
|
||||
if any(s.user_id == user.id for s in other_subs):
|
||||
should_send = False
|
||||
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
|
||||
break
|
||||
|
||||
if not should_send:
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, "expiring", days)
|
||||
all_processed_users.add(user_key)
|
||||
sent_count += 1
|
||||
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
|
||||
else:
|
||||
logger.warning(f"❌ Не удалось отправить уведомление пользователю {user.telegram_id}")
|
||||
|
||||
if 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
|
||||
|
||||
if await notification_sent(db, user.id, subscription.id, "trial_2h"):
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_trial_ending_notification(user, subscription)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, "trial_2h")
|
||||
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
|
||||
|
||||
if trial_expiring:
|
||||
await self._log_monitoring_event(
|
||||
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)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Скорость до 1ГБит/сек
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,665 @@
|
||||
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
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
|
||||
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
|
||||
self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() 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} звезд (~{int(amount_rubles)}₽)")
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
raise
|
||||
|
||||
async def process_stars_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
stars_amount: int,
|
||||
payload: str,
|
||||
telegram_payment_charge_id: str
|
||||
) -> bool:
|
||||
try:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
|
||||
amount_kopeks = int(rubles_amount * 100)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=telegram_payment_charge_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
|
||||
description_for_referral = f"Пополнение Stars: {int(rubles_amount)}₽ ({stars_amount} ⭐)"
|
||||
logger.info(f"🔍 Проверка реферальной логики для описания: '{description_for_referral}'")
|
||||
|
||||
if any(word in description_for_referral.lower() for word in ["пополнение", "stars", "yookassa", "topup"]) and not any(word in description_for_referral.lower() for word in ["комиссия", "бонус"]):
|
||||
logger.info(f"🔞 Вызов process_referral_topup для пользователя {user_id}")
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user_id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения: {e}")
|
||||
else:
|
||||
logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db, user, transaction, old_balance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"⭐ Звезд: {stars_amount}\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {int(rubles_amount)}₽"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
|
||||
logger.info(
|
||||
f"✅ Обработан Stars платеж: пользователь {user_id}, "
|
||||
f"{stars_amount} звезд → {int(rubles_amount)}₽"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Пользователь с ID {user_id} не найден при обработке Stars платежа")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Stars платежа: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def create_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
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:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db, user, transaction, old_balance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении YooKassa: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
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}₽")
|
||||
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}₽")
|
||||
|
||||
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_API_KEY:
|
||||
return False
|
||||
|
||||
try:
|
||||
message = str(data).encode()
|
||||
expected_signature = hmac.new(
|
||||
settings.TRIBUTE_API_KEY.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
|
||||
|
||||
async def create_cryptobot_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_usd: float,
|
||||
asset: str = "USDT",
|
||||
description: str = "Пополнение баланса",
|
||||
payload: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.cryptobot_service:
|
||||
logger.error("CryptoBot сервис не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
amount_str = f"{amount_usd:.2f}"
|
||||
|
||||
invoice_data = await self.cryptobot_service.create_invoice(
|
||||
amount=amount_str,
|
||||
asset=asset,
|
||||
description=description,
|
||||
payload=payload or f"balance_topup_{user_id}_{int(amount_usd * 100)}",
|
||||
expires_in=settings.get_cryptobot_invoice_expires_seconds()
|
||||
)
|
||||
|
||||
if not invoice_data:
|
||||
logger.error("Ошибка создания CryptoBot invoice")
|
||||
return None
|
||||
|
||||
from app.database.crud.cryptobot import create_cryptobot_payment
|
||||
|
||||
local_payment = await create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
invoice_id=str(invoice_data['invoice_id']),
|
||||
amount=amount_str,
|
||||
asset=asset,
|
||||
status="active",
|
||||
description=description,
|
||||
payload=payload,
|
||||
bot_invoice_url=invoice_data.get('bot_invoice_url'),
|
||||
mini_app_invoice_url=invoice_data.get('mini_app_invoice_url'),
|
||||
web_app_invoice_url=invoice_data.get('web_app_invoice_url')
|
||||
)
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж {invoice_data['invoice_id']} на {amount_str} {asset} для пользователя {user_id}")
|
||||
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"invoice_id": str(invoice_data['invoice_id']),
|
||||
"amount": amount_str,
|
||||
"asset": asset,
|
||||
"bot_invoice_url": invoice_data.get('bot_invoice_url'),
|
||||
"mini_app_invoice_url": invoice_data.get('mini_app_invoice_url'),
|
||||
"web_app_invoice_url": invoice_data.get('web_app_invoice_url'),
|
||||
"status": "active",
|
||||
"created_at": local_payment.created_at.isoformat() if local_payment.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
|
||||
return None
|
||||
|
||||
async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
|
||||
try:
|
||||
from app.database.crud.cryptobot import (
|
||||
get_cryptobot_payment_by_invoice_id,
|
||||
update_cryptobot_payment_status,
|
||||
link_cryptobot_payment_to_transaction
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
|
||||
update_type = webhook_data.get("update_type")
|
||||
|
||||
if update_type != "invoice_paid":
|
||||
logger.info(f"Пропуск CryptoBot webhook с типом: {update_type}")
|
||||
return True
|
||||
|
||||
payload = webhook_data.get("payload", {})
|
||||
invoice_id = str(payload.get("invoice_id"))
|
||||
status = "paid"
|
||||
|
||||
if not invoice_id:
|
||||
logger.error("CryptoBot webhook без invoice_id")
|
||||
return False
|
||||
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
if not payment:
|
||||
logger.error(f"CryptoBot платеж не найден в БД: {invoice_id}")
|
||||
return False
|
||||
|
||||
if payment.status == "paid":
|
||||
logger.info(f"CryptoBot платеж {invoice_id} уже обработан")
|
||||
return True
|
||||
|
||||
paid_at_str = payload.get("paid_at")
|
||||
paid_at = None
|
||||
if paid_at_str:
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(paid_at_str.replace('Z', '+00:00')).replace(tzinfo=None)
|
||||
except:
|
||||
paid_at = datetime.utcnow()
|
||||
else:
|
||||
paid_at = datetime.utcnow()
|
||||
|
||||
updated_payment = await update_cryptobot_payment_status(
|
||||
db, invoice_id, status, paid_at
|
||||
)
|
||||
|
||||
if not updated_payment.transaction_id:
|
||||
# Получаем сумму в USD из платежа
|
||||
amount_usd = updated_payment.amount_float
|
||||
|
||||
# Конвертируем в рубли по текущему курсу с улучшенной обработкой ошибок
|
||||
try:
|
||||
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
conversion_rate = amount_rubles / amount_usd if amount_usd > 0 else 0
|
||||
logger.info(f"Конвертация USD->RUB: ${amount_usd} -> {amount_rubles}₽ (курс: {conversion_rate:.2f})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка конвертации валют для платежа {invoice_id}, используем курс 1:1: {e}")
|
||||
amount_rubles = amount_usd
|
||||
amount_kopeks = int(amount_usd * 100)
|
||||
conversion_rate = 1.0
|
||||
|
||||
# Проверяем корректность конвертированной суммы
|
||||
if amount_kopeks <= 0:
|
||||
logger.error(f"Некорректная сумма после конвертации: {amount_kopeks} копеек для платежа {invoice_id}")
|
||||
return False
|
||||
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=updated_payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через CryptoBot ({updated_payment.amount} {updated_payment.asset} → {amount_rubles:.2f}₽)",
|
||||
payment_method=PaymentMethod.CRYPTOBOT,
|
||||
external_id=invoice_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
await link_cryptobot_payment_to_transaction(
|
||||
db, invoice_id, transaction.id
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения CryptoBot: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db, user, transaction, old_balance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении CryptoBot: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {amount_rubles:.2f}₽ ({updated_payment.asset})")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении CryptoBot: {e}")
|
||||
else:
|
||||
logger.error(f"Пользователь с ID {updated_payment.user_id} не найден при пополнении баланса")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки CryptoBot webhook: {e}", exc_info=True)
|
||||
return False
|
||||
@@ -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 "✅ Промокод активирован"
|
||||
@@ -0,0 +1,267 @@
|
||||
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)} или "
|
||||
f"{settings.REFERRAL_COMMISSION_PERCENT}% от суммы (что больше).\n\n"
|
||||
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
|
||||
|
||||
logger.info(f"✅ Зарегистрирован реферал {new_user_id} для {referrer_id}. Бонусы будут выданы после пополнения.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
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)
|
||||
|
||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
|
||||
|
||||
if inviter_bonus > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, inviter_bonus,
|
||||
f"Бонус за первое пополнение реферала {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=inviter_bonus,
|
||||
reason="referral_first_topup"
|
||||
)
|
||||
logger.info(f"💰 Реферер {referrer.telegram_id} получил бонус {inviter_bonus/100}₽")
|
||||
|
||||
if bot:
|
||||
inviter_bonus_notification = (
|
||||
f"💰 <b>Реферальная награда!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
|
||||
f"🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n"
|
||||
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
|
||||
else:
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки пополнения реферала: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def process_referral_purchase(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
purchase_amount_kopeks: int,
|
||||
transaction_id: int = None,
|
||||
bot: Bot = None
|
||||
):
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.referred_by_id:
|
||||
return True
|
||||
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if not referrer:
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100):
|
||||
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный!")
|
||||
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
@@ -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)
|
||||
@@ -0,0 +1,357 @@
|
||||
import logging
|
||||
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
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
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_telegram_id = payment_data["user_id"]
|
||||
amount_kopeks = payment_data["amount_kopeks"]
|
||||
payment_id = payment_data["payment_id"]
|
||||
|
||||
logger.info(f"Обрабатываем успешный Tribute платеж: user_telegram_id={user_telegram_id}, amount={amount_kopeks}, payment_id={payment_id}")
|
||||
|
||||
async for session in get_db():
|
||||
user = await get_user_by_telegram_id(session, user_telegram_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_telegram_id} не найден")
|
||||
return
|
||||
|
||||
logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп")
|
||||
|
||||
from app.database.crud.transaction import check_tribute_payment_duplicate
|
||||
|
||||
duplicate_transaction = await check_tribute_payment_duplicate(
|
||||
session, payment_id, amount_kopeks, user_telegram_id
|
||||
)
|
||||
|
||||
if duplicate_transaction:
|
||||
logger.warning(f"Найден дубликат платежа в течение 24ч:")
|
||||
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
|
||||
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
|
||||
logger.warning(f" Created: {duplicate_transaction.created_at}")
|
||||
logger.warning(f" External ID: {duplicate_transaction.external_id}")
|
||||
logger.warning(f"Платеж игнорирован - это дубликат свежего платежа")
|
||||
return
|
||||
|
||||
from app.database.crud.transaction import create_unique_tribute_transaction
|
||||
|
||||
transaction = await create_unique_tribute_transaction(
|
||||
db=session,
|
||||
user_id=user.id,
|
||||
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_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
|
||||
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(session, user.id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения Tribute: {e}")
|
||||
|
||||
if not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}")
|
||||
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
session, user, transaction, old_balance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
||||
|
||||
await self._send_success_notification(user_telegram_id, amount_kopeks)
|
||||
|
||||
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⌘ Ошибка обработки успешного Tribute платежа: {e}", exc_info=True)
|
||||
|
||||
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"💰 Сумма: {int(amount_rubles)} ₽\n"
|
||||
f"💳 Способ оплаты: Tribute\n"
|
||||
f"🎉 Средства зачислены на баланс!\n\n"
|
||||
f"Спасибо за оплату! 🙏"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
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"💰 Сумма возврата: {int(amount_rubles)} ₽\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)
|
||||
@@ -0,0 +1,564 @@
|
||||
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, CryptoBotPayment
|
||||
)
|
||||
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 UserMessage
|
||||
from sqlalchemy import update
|
||||
|
||||
result = await db.execute(
|
||||
update(UserMessage)
|
||||
.where(UserMessage.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {result.rowcount} пользовательских сообщений")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления пользовательских сообщений: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import PromoCode
|
||||
from sqlalchemy import update
|
||||
|
||||
result = await db.execute(
|
||||
update(PromoCode)
|
||||
.where(PromoCode.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {result.rowcount} промокодов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления промокодов: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import YooKassaPayment
|
||||
from sqlalchemy import select
|
||||
|
||||
yookassa_result = await db.execute(
|
||||
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
yookassa_payments = yookassa_result.scalars().all()
|
||||
|
||||
if yookassa_payments:
|
||||
logger.info(f"🔄 Удаляем {len(yookassa_payments)} YooKassa платежей")
|
||||
await db.execute(
|
||||
delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ YooKassa платежи удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления YooKassa платежей: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import CryptoBotPayment
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
cryptobot_result = await db.execute(
|
||||
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
|
||||
)
|
||||
cryptobot_payments = cryptobot_result.scalars().all()
|
||||
|
||||
if cryptobot_payments:
|
||||
logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей")
|
||||
await db.execute(
|
||||
delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ CryptoBot платежи удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
)
|
||||
transactions = transactions_result.scalars().all()
|
||||
|
||||
if transactions:
|
||||
logger.info(f"🔄 Удаляем {len(transactions)} транзакций")
|
||||
await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ Транзакции удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления транзакций: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены использования промокодов пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления использований промокодов: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены реферальные доходы пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления реферальных доходов: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены реферальные записи о пользователе {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления реферальных записей: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import BroadcastHistory
|
||||
await db.execute(
|
||||
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалена история рассылок админа {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import SubscriptionConversion
|
||||
conversions_result = await db.execute(
|
||||
select(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
|
||||
)
|
||||
conversions = conversions_result.scalars().all()
|
||||
|
||||
if conversions:
|
||||
logger.info(f"🔄 Удаляем {len(conversions)} записей конверсий")
|
||||
await db.execute(
|
||||
delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ Записи конверсий удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления записей конверсий: {e}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
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 []
|
||||
@@ -0,0 +1,267 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import aiohttp
|
||||
from packaging import version
|
||||
import re
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VersionInfo:
|
||||
def __init__(self, tag_name: str, published_at: str, name: str, body: str, prerelease: bool = False):
|
||||
self.tag_name = tag_name
|
||||
self.published_at = datetime.fromisoformat(published_at.replace('Z', '+00:00'))
|
||||
self.name = name or tag_name
|
||||
self.body = body
|
||||
self.prerelease = prerelease
|
||||
self.is_dev = 'dev' in tag_name.lower()
|
||||
|
||||
@property
|
||||
def clean_version(self) -> str:
|
||||
return re.sub(r'^v', '', self.tag_name)
|
||||
|
||||
@property
|
||||
def version_obj(self):
|
||||
try:
|
||||
clean_ver = self.clean_version
|
||||
if 'dev' in clean_ver:
|
||||
base_ver = clean_ver.split('-dev')[0]
|
||||
return version.parse(f"{base_ver}.dev")
|
||||
return version.parse(clean_ver)
|
||||
except Exception:
|
||||
return version.parse("0.0.0")
|
||||
|
||||
@property
|
||||
def formatted_date(self) -> str:
|
||||
return self.published_at.strftime('%d.%m.%Y %H:%M')
|
||||
|
||||
@property
|
||||
def short_description(self) -> str:
|
||||
if not self.body:
|
||||
return "Без описания"
|
||||
|
||||
description = self.body.strip()
|
||||
if len(description) > 350:
|
||||
description = description[:347] + "..."
|
||||
|
||||
return description
|
||||
|
||||
|
||||
class VersionService:
|
||||
def __init__(self, bot=None):
|
||||
self.bot = bot
|
||||
self.repo = getattr(settings, 'VERSION_CHECK_REPO', 'fr1ngg/remnawave-bedolaga-telegram-bot')
|
||||
self.enabled = getattr(settings, 'VERSION_CHECK_ENABLED', True)
|
||||
self.current_version = self._get_current_version()
|
||||
self.cache_ttl = 3600
|
||||
self._cache: Dict = {}
|
||||
self._last_check: Optional[datetime] = None
|
||||
self._notification_service = None
|
||||
|
||||
async def get_latest_stable_version(self) -> str:
|
||||
try:
|
||||
url = f"https://api.github.com/repos/{self.repo}/releases/latest"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data['tag_name']
|
||||
except Exception:
|
||||
pass
|
||||
return "UNKNOW"
|
||||
|
||||
def _get_current_version(self) -> str:
|
||||
import os
|
||||
|
||||
current = os.getenv('VERSION', '').strip()
|
||||
|
||||
if current:
|
||||
if '-' in current and current.startswith('v'):
|
||||
base_version = current.split('-')[0]
|
||||
if base_version.count('.') == 2:
|
||||
return base_version
|
||||
return current
|
||||
|
||||
return "UNKNOW"
|
||||
|
||||
def set_notification_service(self, notification_service):
|
||||
self._notification_service = notification_service
|
||||
|
||||
async def check_for_updates(self, force: bool = False) -> Tuple[bool, List[VersionInfo]]:
|
||||
if not self.enabled:
|
||||
return False, []
|
||||
|
||||
try:
|
||||
releases = await self._fetch_releases(force)
|
||||
if not releases:
|
||||
return False, []
|
||||
|
||||
current_ver = self._parse_version(self.current_version)
|
||||
newer_releases = []
|
||||
|
||||
for release in releases:
|
||||
release_ver = release.version_obj
|
||||
if release_ver > current_ver:
|
||||
newer_releases.append(release)
|
||||
|
||||
newer_releases.sort(key=lambda x: x.version_obj, reverse=True)
|
||||
|
||||
has_updates = len(newer_releases) > 0
|
||||
|
||||
if has_updates and not force:
|
||||
await self._send_update_notification(newer_releases)
|
||||
|
||||
return has_updates, newer_releases
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки обновлений: {e}")
|
||||
return False, []
|
||||
|
||||
async def _fetch_releases(self, force: bool = False) -> List[VersionInfo]:
|
||||
if not force and self._cache and self._last_check:
|
||||
if datetime.now() - self._last_check < timedelta(seconds=self.cache_ttl):
|
||||
return self._cache.get('releases', [])
|
||||
|
||||
url = f"https://api.github.com/repos/{self.repo}/releases"
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
releases = []
|
||||
|
||||
for release_data in data[:20]:
|
||||
release = VersionInfo(
|
||||
tag_name=release_data['tag_name'],
|
||||
published_at=release_data['published_at'],
|
||||
name=release_data['name'],
|
||||
body=release_data['body'] or '',
|
||||
prerelease=release_data['prerelease']
|
||||
)
|
||||
releases.append(release)
|
||||
|
||||
self._cache['releases'] = releases
|
||||
self._last_check = datetime.now()
|
||||
|
||||
logger.info(f"Получено {len(releases)} релизов из GitHub")
|
||||
return releases
|
||||
else:
|
||||
logger.warning(f"GitHub API вернул статус {response.status}")
|
||||
return []
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Таймаут при запросе к GitHub API")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запроса к GitHub API: {e}")
|
||||
return []
|
||||
|
||||
def _parse_version(self, version_str: str):
|
||||
try:
|
||||
clean_ver = re.sub(r'^v', '', version_str)
|
||||
if 'dev' in clean_ver:
|
||||
base_ver = clean_ver.split('-dev')[0]
|
||||
return version.parse(f"{base_ver}.dev")
|
||||
if 'unknow' in clean_ver.lower():
|
||||
return version.parse("0.0.0")
|
||||
return version.parse(clean_ver)
|
||||
except Exception:
|
||||
return version.parse("0.0.0")
|
||||
|
||||
async def _send_update_notification(self, newer_releases: List[VersionInfo]):
|
||||
if not self._notification_service or not newer_releases:
|
||||
return
|
||||
|
||||
try:
|
||||
latest_version = newer_releases[0]
|
||||
cache_key = f"notified_{latest_version.tag_name}"
|
||||
|
||||
if self._cache.get(cache_key):
|
||||
return
|
||||
|
||||
await self._notification_service.send_version_update_notification(
|
||||
current_version=self.current_version,
|
||||
latest_version=latest_version,
|
||||
total_updates=len(newer_releases)
|
||||
)
|
||||
|
||||
self._cache[cache_key] = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об обновлении: {e}")
|
||||
|
||||
async def get_version_info(self) -> Dict:
|
||||
try:
|
||||
has_updates, newer_releases = await self.check_for_updates()
|
||||
all_releases = await self._fetch_releases()
|
||||
|
||||
current_release = None
|
||||
current_ver = self._parse_version(self.current_version)
|
||||
|
||||
for release in all_releases:
|
||||
if release.version_obj == current_ver:
|
||||
current_release = release
|
||||
break
|
||||
|
||||
return {
|
||||
'current_version': self.current_version,
|
||||
'current_release': current_release,
|
||||
'has_updates': has_updates,
|
||||
'newer_releases': newer_releases[:5],
|
||||
'total_newer': len(newer_releases),
|
||||
'last_check': self._last_check,
|
||||
'repo_url': f"https://github.com/{self.repo}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о версиях: {e}")
|
||||
return {
|
||||
'current_version': self.current_version,
|
||||
'current_release': None,
|
||||
'has_updates': False,
|
||||
'newer_releases': [],
|
||||
'total_newer': 0,
|
||||
'last_check': None,
|
||||
'repo_url': f"https://github.com/{self.repo}",
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
async def start_periodic_check(self):
|
||||
if not self.enabled:
|
||||
logger.info("Проверка версий отключена")
|
||||
return
|
||||
|
||||
logger.info(f"Запуск периодической проверки обновлений для {self.repo}")
|
||||
logger.info(f"Текущая версия: {self.current_version}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
await self.check_for_updates()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Остановка проверки обновлений")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в периодической проверке обновлений: {e}")
|
||||
await asyncio.sleep(300)
|
||||
|
||||
def format_version_display(self, version_info: VersionInfo) -> str:
|
||||
status_icon = ""
|
||||
if version_info.prerelease:
|
||||
status_icon = "🧪"
|
||||
elif version_info.is_dev:
|
||||
status_icon = "🔧"
|
||||
else:
|
||||
status_icon = "📦"
|
||||
|
||||
return f"{status_icon} {version_info.tag_name}"
|
||||
|
||||
|
||||
version_service = VersionService()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,121 @@
|
||||
import logging
|
||||
import aiohttp
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CurrencyConverter:
|
||||
|
||||
def __init__(self):
|
||||
self._cache = {}
|
||||
self._cache_ttl = 3600 # 1 час
|
||||
self._last_update = {}
|
||||
|
||||
async def get_usd_to_rub_rate(self) -> float:
|
||||
"""Получает курс USD/RUB с кешированием"""
|
||||
|
||||
cache_key = "USD_RUB"
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Проверяем кеш
|
||||
if (cache_key in self._cache and
|
||||
cache_key in self._last_update and
|
||||
(now - self._last_update[cache_key]).seconds < self._cache_ttl):
|
||||
return self._cache[cache_key]
|
||||
|
||||
# Получаем новый курс
|
||||
rate = await self._fetch_exchange_rate()
|
||||
|
||||
if rate:
|
||||
self._cache[cache_key] = rate
|
||||
self._last_update[cache_key] = now
|
||||
logger.info(f"Обновлен курс USD/RUB: {rate}")
|
||||
return rate
|
||||
|
||||
# Возвращаем из кеша если API недоступен
|
||||
if cache_key in self._cache:
|
||||
logger.warning("API курсов недоступен, используем кешированный курс")
|
||||
return self._cache[cache_key]
|
||||
|
||||
# Fallback курс
|
||||
logger.warning("Используем fallback курс USD/RUB: 95")
|
||||
return 95.0
|
||||
|
||||
async def _fetch_exchange_rate(self) -> Optional[float]:
|
||||
"""Получает курс с нескольких источников"""
|
||||
|
||||
sources = [
|
||||
self._fetch_from_cbr,
|
||||
self._fetch_from_exchangerate_api,
|
||||
self._fetch_from_fixer
|
||||
]
|
||||
|
||||
for source in sources:
|
||||
try:
|
||||
rate = await source()
|
||||
if rate and 50 < rate < 200: # Разумные границы курса
|
||||
return rate
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка получения курса из {source.__name__}: {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
async def _fetch_from_cbr(self) -> Optional[float]:
|
||||
"""Получает курс с сайта ЦБ РФ"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
||||
async with session.get('https://www.cbr-xml-daily.ru/daily_json.js') as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
usd_rate = data['Valute']['USD']['Value']
|
||||
return float(usd_rate)
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка получения курса ЦБ: {e}")
|
||||
return None
|
||||
|
||||
async def _fetch_from_exchangerate_api(self) -> Optional[float]:
|
||||
"""Получает курс с exchangerate-api.com"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
||||
async with session.get('https://api.exchangerate-api.com/v4/latest/USD') as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
rub_rate = data['rates']['RUB']
|
||||
return float(rub_rate)
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка получения курса exchangerate-api: {e}")
|
||||
return None
|
||||
|
||||
async def _fetch_from_fixer(self) -> Optional[float]:
|
||||
"""Получает курс с fixer.io (бесплатный план)"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
||||
# Используем бесплатный endpoint (EUR base)
|
||||
async with session.get('https://api.fixer.io/latest?access_key=YOUR_API_KEY&symbols=USD,RUB') as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
if data.get('success'):
|
||||
# Конвертируем EUR -> USD -> RUB
|
||||
usd_eur = data['rates']['USD']
|
||||
rub_eur = data['rates']['RUB']
|
||||
usd_rub = rub_eur / usd_eur
|
||||
return float(usd_rub)
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка получения курса fixer: {e}")
|
||||
return None
|
||||
|
||||
async def usd_to_rub(self, usd_amount: float) -> float:
|
||||
"""Конвертирует USD в RUB"""
|
||||
rate = await self.get_usd_to_rub_rate()
|
||||
return usd_amount * rate
|
||||
|
||||
async def rub_to_usd(self, rub_amount: float) -> float:
|
||||
"""Конвертирует RUB в USD"""
|
||||
rate = await self.get_usd_to_rub_rate()
|
||||
return rub_amount / rate
|
||||
|
||||
# Глобальный экземпляр
|
||||
currency_converter = CurrencyConverter()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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': []
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from datetime import datetime
|
||||
import html
|
||||
|
||||
ALLOWED_HTML_TAGS = {
|
||||
'b', 'strong',
|
||||
'i', 'em',
|
||||
'u', 'ins',
|
||||
's', 'strike', 'del',
|
||||
'code',
|
||||
'pre',
|
||||
'a',
|
||||
'blockquote'
|
||||
}
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
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:
|
||||
if not text:
|
||||
return text
|
||||
|
||||
text = html.escape(text)
|
||||
|
||||
for tag in ALLOWED_HTML_TAGS:
|
||||
text = re.sub(
|
||||
f'<{tag}(>|\\s[^&]*>)',
|
||||
lambda m: m.group(0).replace('<', '<').replace('>', '>'),
|
||||
text,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
text = re.sub(
|
||||
f'</{tag}>',
|
||||
f'</{tag}>',
|
||||
text,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
if not text:
|
||||
return True, ""
|
||||
|
||||
tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*>'
|
||||
tags = re.findall(tag_pattern, text)
|
||||
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
if tag_name_lower not in ALLOWED_HTML_TAGS:
|
||||
return False, f"Неподдерживаемый тег: <{tag_name}>"
|
||||
|
||||
tag_stack = []
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
if not is_closing:
|
||||
tag_stack.append(tag_name_lower)
|
||||
else:
|
||||
if not tag_stack:
|
||||
return False, f"Закрывающий тег без открывающего: </{tag_name}>"
|
||||
|
||||
last_tag = tag_stack.pop()
|
||||
if last_tag != tag_name_lower:
|
||||
return False, f"Неправильная вложенность тегов: ожидался </{last_tag}>, найден </{tag_name}>"
|
||||
|
||||
if tag_stack:
|
||||
return False, f"Незакрытый тег: <{tag_stack[-1]}>"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_html_help_text() -> str:
|
||||
return """<b>Поддерживаемые HTML теги:</b>
|
||||
|
||||
- <code><b>жирный</b></code> или <code><strong>жирный</strong></code>
|
||||
- <code><i>курсив</i></code> или <code><em>курсив</em></code>
|
||||
- <code><u>подчеркнутый</u></code>
|
||||
- <code><s>зачеркнутый</s></code>
|
||||
- <code><code>моноширинный</code></code>
|
||||
- <code><pre>блок кода</pre></code>
|
||||
- <code><a href="url">ссылка</a></code>
|
||||
- <code><blockquote>цитата</blockquote></code>
|
||||
|
||||
<b>Неподдерживаемые теги:</b> <br>, <p>, <div>, <span>, <spoiler> и другие"""
|
||||
+1
-1
@@ -261,7 +261,7 @@
|
||||
text-anchor="middle"
|
||||
fill="url(#mainGradient)"
|
||||
filter="url(#glow)">
|
||||
RemnaWave
|
||||
Remnawave
|
||||
</text>
|
||||
|
||||
<text x="200" y="345"
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,284 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from database import Database, UserSubscription, User, Subscription
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from referral_utils import process_referral_rewards
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AutoPayService:
|
||||
|
||||
def __init__(self, db: Database, api: Optional[RemnaWaveAPI] = None, bot=None):
|
||||
self.db = db
|
||||
self.api = api
|
||||
self.bot = bot
|
||||
self.is_running = False
|
||||
self.check_task = None
|
||||
|
||||
async def start(self):
|
||||
if self.is_running:
|
||||
logger.warning("AutoPay service is already running")
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
logger.info("🔄 Starting AutoPay service...")
|
||||
|
||||
self.check_task = asyncio.create_task(self._periodic_check())
|
||||
|
||||
async def stop(self):
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
self.is_running = False
|
||||
logger.info("⏹ Stopping AutoPay service...")
|
||||
|
||||
if self.check_task:
|
||||
self.check_task.cancel()
|
||||
try:
|
||||
await self.check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _periodic_check(self):
|
||||
while self.is_running:
|
||||
try:
|
||||
await self.process_autopayments()
|
||||
# Проверяем каждые 30 минут
|
||||
await asyncio.sleep(1800)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("AutoPay periodic check cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in AutoPay periodic check: {e}")
|
||||
await asyncio.sleep(300)
|
||||
|
||||
async def process_autopayments(self) -> dict:
|
||||
logger.info("🔄 Processing autopayments...")
|
||||
|
||||
try:
|
||||
subscriptions_to_pay = await self.db.get_subscriptions_for_autopay()
|
||||
|
||||
if not subscriptions_to_pay:
|
||||
logger.info("No subscriptions ready for autopay")
|
||||
return {
|
||||
'processed': 0,
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'insufficient_balance': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
logger.info(f"Found {len(subscriptions_to_pay)} subscriptions for autopay")
|
||||
|
||||
stats = {
|
||||
'processed': 0,
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'insufficient_balance': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
for user_sub in subscriptions_to_pay:
|
||||
try:
|
||||
result = await self._process_single_autopayment(user_sub)
|
||||
stats['processed'] += 1
|
||||
|
||||
if result['success']:
|
||||
stats['successful'] += 1
|
||||
logger.info(f"✅ Autopay successful for user {user_sub.user_id}, subscription {user_sub.id}")
|
||||
elif result['reason'] == 'insufficient_balance':
|
||||
stats['insufficient_balance'] += 1
|
||||
logger.info(f"💳 Insufficient balance for user {user_sub.user_id}, subscription {user_sub.id}")
|
||||
else:
|
||||
stats['failed'] += 1
|
||||
stats['errors'].append(f"User {user_sub.user_id}: {result['reason']}")
|
||||
logger.warning(f"❌ Autopay failed for user {user_sub.user_id}: {result['reason']}")
|
||||
|
||||
except Exception as e:
|
||||
stats['processed'] += 1
|
||||
stats['failed'] += 1
|
||||
error_msg = f"User {user_sub.user_id}: {str(e)}"
|
||||
stats['errors'].append(error_msg)
|
||||
logger.error(f"Error processing autopay for user {user_sub.user_id}: {e}")
|
||||
|
||||
logger.info(f"📊 Autopay processing complete: {stats['successful']} successful, "
|
||||
f"{stats['failed']} failed, {stats['insufficient_balance']} insufficient balance")
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in process_autopayments: {e}")
|
||||
return {
|
||||
'processed': 0,
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'insufficient_balance': 0,
|
||||
'errors': [str(e)]
|
||||
}
|
||||
|
||||
async def _process_single_autopayment(self, user_sub: UserSubscription) -> dict:
|
||||
try:
|
||||
user = await self.db.get_user_by_telegram_id(user_sub.user_id)
|
||||
if not user:
|
||||
return {'success': False, 'reason': 'User not found'}
|
||||
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription:
|
||||
return {'success': False, 'reason': 'Subscription plan not found'}
|
||||
|
||||
if subscription.is_trial:
|
||||
logger.info(f"Skipping autopay for trial subscription: user {user_sub.user_id}")
|
||||
return {'success': False, 'reason': 'Trial subscriptions are not eligible for autopay'}
|
||||
|
||||
if not user_sub.is_active or not user_sub.auto_pay_enabled:
|
||||
return {'success': False, 'reason': 'Subscription inactive or autopay disabled'}
|
||||
|
||||
if user.balance < subscription.price:
|
||||
await self._notify_insufficient_balance(user, subscription, user_sub)
|
||||
return {'success': False, 'reason': 'insufficient_balance'}
|
||||
|
||||
return await self._execute_autopayment(user, subscription, user_sub)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in _process_single_autopayment: {e}")
|
||||
return {'success': False, 'reason': str(e)}
|
||||
|
||||
async def _execute_autopayment(self, user: User, subscription: Subscription, user_sub: UserSubscription) -> dict:
|
||||
try:
|
||||
user.balance -= subscription.price
|
||||
await self.db.update_user(user)
|
||||
|
||||
now = datetime.utcnow()
|
||||
if user_sub.expires_at > now:
|
||||
new_expiry = user_sub.expires_at + timedelta(days=subscription.duration_days)
|
||||
else:
|
||||
new_expiry = now + timedelta(days=subscription.duration_days)
|
||||
|
||||
user_sub.expires_at = new_expiry
|
||||
user_sub.is_active = True
|
||||
await self.db.update_user_subscription(user_sub)
|
||||
|
||||
if self.api and user_sub.short_uuid:
|
||||
try:
|
||||
remna_user_details = await self.api.get_user_by_short_uuid(user_sub.short_uuid)
|
||||
if remna_user_details:
|
||||
user_uuid = remna_user_details.get('uuid')
|
||||
if user_uuid:
|
||||
expiry_str = new_expiry.isoformat() + 'Z'
|
||||
update_data = {
|
||||
'enable': True,
|
||||
'expireAt': expiry_str
|
||||
}
|
||||
await self.api.update_user(user_uuid, update_data)
|
||||
logger.info(f"Updated RemnaWave expiry for user {user_sub.user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update RemnaWave expiry: {e}")
|
||||
|
||||
payment = await self.db.create_payment(
|
||||
user_id=user_sub.user_id,
|
||||
amount=-subscription.price,
|
||||
payment_type='autopay',
|
||||
description=f'Автоплатеж: {subscription.name}',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await process_referral_rewards(
|
||||
user_sub.user_id,
|
||||
subscription.price,
|
||||
payment.id,
|
||||
self.db,
|
||||
self.bot,
|
||||
payment_type='autopay'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process referral rewards for autopay: {e}")
|
||||
|
||||
await self._notify_successful_autopay(user, subscription, user_sub, new_expiry)
|
||||
|
||||
return {'success': True, 'reason': 'Payment processed successfully'}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing autopayment: {e}")
|
||||
try:
|
||||
user.balance += subscription.price
|
||||
await self.db.update_user(user)
|
||||
except:
|
||||
pass
|
||||
return {'success': False, 'reason': str(e)}
|
||||
|
||||
async def _notify_successful_autopay(self, user: User, subscription: Subscription,
|
||||
user_sub: UserSubscription, new_expiry: datetime):
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
from utils import format_datetime
|
||||
|
||||
text = f"✅ Автоматическое продление подписки\n\n"
|
||||
text += f"📋 Подписка: {subscription.name}\n"
|
||||
text += f"💰 Списано: {subscription.price} руб.\n"
|
||||
text += f"📅 Продлено до: {format_datetime(new_expiry, user.language)}\n"
|
||||
text += f"💳 Остаток на балансе: {user.balance} руб.\n\n"
|
||||
text += f"🔄 Следующее продление произойдет автоматически за {user_sub.auto_pay_days_before} дн. до истечения.\n\n"
|
||||
text += f"ℹ️ Для отключения автоплатежа перейдите в 'Мои подписки' → выберите подписку"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")],
|
||||
[InlineKeyboardButton(text="💰 Баланс", callback_data="balance")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
text,
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send autopay success notification: {e}")
|
||||
|
||||
async def _notify_insufficient_balance(self, user: User, subscription: Subscription, user_sub: UserSubscription):
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
from utils import format_datetime
|
||||
|
||||
needed = subscription.price - user.balance
|
||||
days_left = (user_sub.expires_at - datetime.utcnow()).days
|
||||
|
||||
text = f"⚠️ Не удалось автоматически продлить подписку\n\n"
|
||||
text += f"📋 Подписка: {subscription.name}\n"
|
||||
text += f"💰 Нужно для продления: {subscription.price} руб.\n"
|
||||
text += f"💳 Ваш баланс: {user.balance} руб.\n"
|
||||
text += f"💸 Недостает: {needed} руб.\n\n"
|
||||
text += f"📅 Подписка истекает: {format_datetime(user_sub.expires_at, user.language)}\n"
|
||||
text += f"⏰ Осталось дней: {days_left}\n\n"
|
||||
text += f"💡 Пополните баланс для автоматического продления"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="topup_balance")],
|
||||
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
text,
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send insufficient balance notification: {e}")
|
||||
|
||||
async def get_service_status(self) -> dict:
|
||||
return {
|
||||
'is_running': self.is_running,
|
||||
'check_interval': 1800, # 30 минут
|
||||
'has_api': self.api is not None,
|
||||
'has_bot': self.bot is not None
|
||||
}
|
||||
-965
@@ -1,965 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Цвета для вывода
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
BOT_DIR="/opt/bedolaga-bot"
|
||||
COMPOSE_FILE="$BOT_DIR/docker-compose.yml"
|
||||
ENV_FILE="$BOT_DIR/.env"
|
||||
SERVICE_FILE="/etc/systemd/system/bedolaga-bot.service"
|
||||
|
||||
# Функция для логирования
|
||||
log() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Проверка является ли пользователь root
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
error "Этот скрипт должен быть запущен от имени root (используйте sudo)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Проверка установки бота
|
||||
check_installation() {
|
||||
if [ -d "$BOT_DIR" ] && [ -f "$COMPOSE_FILE" ] && [ -f "$ENV_FILE" ]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Обновление системы
|
||||
update_system() {
|
||||
log "Обновление системы Ubuntu..."
|
||||
apt update && apt upgrade -y
|
||||
log "Система обновлена успешно"
|
||||
}
|
||||
|
||||
# Установка Docker
|
||||
install_docker() {
|
||||
log "Установка Docker..."
|
||||
|
||||
# Удаление старых версий
|
||||
apt remove -y docker docker-engine docker.io containerd runc
|
||||
|
||||
# Установка зависимостей
|
||||
apt install -y apt-transport-https ca-certificates curl gnupg lsb-release nano
|
||||
|
||||
# Добавление GPG ключа Docker
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
|
||||
# Добавление репозитория
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# Установка Docker
|
||||
apt update
|
||||
apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
|
||||
# Запуск Docker
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
|
||||
log "Docker установлен успешно"
|
||||
}
|
||||
|
||||
# Проверка и установка nano
|
||||
ensure_nano() {
|
||||
if ! command -v nano &> /dev/null; then
|
||||
log "Установка nano..."
|
||||
apt update
|
||||
apt install -y nano
|
||||
log "Nano установлен успешно"
|
||||
fi
|
||||
}
|
||||
|
||||
# Создание папки и структуры проекта
|
||||
create_project_structure() {
|
||||
log "Создание структуры проекта..."
|
||||
|
||||
mkdir -p "$BOT_DIR"
|
||||
mkdir -p "$BOT_DIR/logs"
|
||||
mkdir -p "$BOT_DIR/data"
|
||||
|
||||
log "Структура проекта создана в $BOT_DIR"
|
||||
}
|
||||
|
||||
# Создание docker-compose.yml
|
||||
create_docker_compose() {
|
||||
log "Создание docker-compose.yml..."
|
||||
|
||||
echo "Выберите конфигурацию Docker Compose:"
|
||||
echo "1) Минимальная (рекомендуется) - только PostgreSQL и бот"
|
||||
echo "2) Расширенная - с Redis и Nginx"
|
||||
|
||||
while true; do
|
||||
read -p "Ваш выбор (1-2): " choice
|
||||
case $choice in
|
||||
1)
|
||||
create_minimal_compose
|
||||
break
|
||||
;;
|
||||
2)
|
||||
create_full_compose
|
||||
break
|
||||
;;
|
||||
*)
|
||||
error "Неверный выбор. Попробуйте снова."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
create_minimal_compose() {
|
||||
cat > "$COMPOSE_FILE" << 'EOF'
|
||||
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"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U remnawave_user -d remnawave_bot"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# RemnaWave Bot
|
||||
bot:
|
||||
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
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
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
driver: bridge
|
||||
EOF
|
||||
|
||||
log "Минимальная конфигурация Docker Compose создана"
|
||||
}
|
||||
|
||||
create_full_compose() {
|
||||
cat > "$COMPOSE_FILE" << 'EOF'
|
||||
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"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U remnawave_user -d remnawave_bot"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# RemnaWave Bot
|
||||
bot:
|
||||
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
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 (для кэширования и улучшения производительности)
|
||||
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
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
profiles:
|
||||
- with-redis
|
||||
|
||||
# Nginx (для статических файлов или веб-интерфейса)
|
||||
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
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
EOF
|
||||
|
||||
log "Расширенная конфигурация Docker Compose создана"
|
||||
}
|
||||
|
||||
# Создание .env файла
|
||||
create_env_file() {
|
||||
log "Настройка .env файла..."
|
||||
|
||||
# Основные настройки бота
|
||||
read -p "Введите BOT_TOKEN: " BOT_TOKEN
|
||||
read -p "Введите BOT_USERNAME (без @): " BOT_USERNAME
|
||||
read -p "Введите ADMIN_IDS (через запятую): " ADMIN_IDS
|
||||
|
||||
# Настройки RemnaWave
|
||||
echo "Выберите режим RemnaWave:"
|
||||
echo "1) remote"
|
||||
echo "2) local"
|
||||
while true; do
|
||||
read -p "Ваш выбор (1-2): " remna_choice
|
||||
case $remna_choice in
|
||||
1)
|
||||
REMNAWAVE_MODE="remote"
|
||||
break
|
||||
;;
|
||||
2)
|
||||
REMNAWAVE_MODE="local"
|
||||
break
|
||||
;;
|
||||
*)
|
||||
error "Неверный выбор. Попробуйте снова."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
read -p "Введите REMNAWAVE_URL: " REMNAWAVE_URL
|
||||
read -p "Введите REMNAWAVE_TOKEN: " REMNAWAVE_TOKEN
|
||||
|
||||
# Настройки триала
|
||||
while true; do
|
||||
read -p "Включить триал? (y/n): " trial_enabled
|
||||
case $trial_enabled in
|
||||
[Yy]*)
|
||||
TRIAL_ENABLED="true"
|
||||
read -p "Введите TRIAL_DURATION_DAYS: " TRIAL_DURATION_DAYS
|
||||
read -p "Введите TRIAL_TRAFFIC_GB: " TRIAL_TRAFFIC_GB
|
||||
read -p "Введите TRIAL_SQUAD_UUID: " TRIAL_SQUAD_UUID
|
||||
break
|
||||
;;
|
||||
[Nn]*)
|
||||
TRIAL_ENABLED="false"
|
||||
TRIAL_DURATION_DAYS=""
|
||||
TRIAL_TRAFFIC_GB=""
|
||||
TRIAL_SQUAD_UUID=""
|
||||
break
|
||||
;;
|
||||
*)
|
||||
error "Пожалуйста, ответьте y или n."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Настройки реферальной системы
|
||||
read -p "Введите REFERRAL_FIRST_REWARD (сумму с .0 на конце): " REFERRAL_FIRST_REWARD
|
||||
read -p "Введите REFERRAL_REFERRED_BONUS (сумму с .0 на конце): " REFERRAL_REFERRED_BONUS
|
||||
read -p "Введите REFERRAL_THRESHOLD (сумму с .0 на конце): " REFERRAL_THRESHOLD
|
||||
read -p "Введите REFERRAL_PERCENTAGE (с 0. в начале): " REFERRAL_PERCENTAGE
|
||||
|
||||
# Настройки мониторинга
|
||||
read -p "Введите DELETE_EXPIRED_TRIAL_DAYS: " DELETE_EXPIRED_TRIAL_DAYS
|
||||
read -p "Введите DELETE_EXPIRED_REGULAR_DAYS: " DELETE_EXPIRED_REGULAR_DAYS
|
||||
|
||||
# Создание .env файла
|
||||
cat > "$ENV_FILE" << EOF
|
||||
BOT_TOKEN=$BOT_TOKEN
|
||||
BOT_USERNAME=$BOT_USERNAME
|
||||
ADMIN_IDS=$ADMIN_IDS
|
||||
REMNAWAVE_MODE=$REMNAWAVE_MODE
|
||||
REMNAWAVE_URL=$REMNAWAVE_URL
|
||||
REMNAWAVE_TOKEN=$REMNAWAVE_TOKEN
|
||||
TRIAL_ENABLED=$TRIAL_ENABLED
|
||||
EOF
|
||||
|
||||
if [ "$TRIAL_ENABLED" = "true" ]; then
|
||||
cat >> "$ENV_FILE" << EOF
|
||||
TRIAL_DURATION_DAYS=$TRIAL_DURATION_DAYS
|
||||
TRIAL_TRAFFIC_GB=$TRIAL_TRAFFIC_GB
|
||||
TRIAL_SQUAD_UUID=$TRIAL_SQUAD_UUID
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat >> "$ENV_FILE" << EOF
|
||||
TRIAL_PRICE=0.0
|
||||
|
||||
REFERRAL_FIRST_REWARD=$REFERRAL_FIRST_REWARD
|
||||
REFERRAL_REFERRED_BONUS=$REFERRAL_REFERRED_BONUS
|
||||
REFERRAL_THRESHOLD=$REFERRAL_THRESHOLD
|
||||
REFERRAL_PERCENTAGE=$REFERRAL_PERCENTAGE
|
||||
|
||||
# Monitor Service Settings
|
||||
MONITOR_ENABLED=true
|
||||
MONITOR_CHECK_INTERVAL=21600
|
||||
MONITOR_DAILY_CHECK_HOUR=12
|
||||
MONITOR_WARNING_DAYS=2
|
||||
DELETE_EXPIRED_TRIAL_DAYS=$DELETE_EXPIRED_TRIAL_DAYS
|
||||
DELETE_EXPIRED_REGULAR_DAYS=$DELETE_EXPIRED_REGULAR_DAYS
|
||||
AUTO_DELETE_ENABLED=true
|
||||
LUCKY_GAME_ENABLED=true
|
||||
LUCKY_GAME_REWARD=50.0
|
||||
LUCKY_GAME_NUMBERS=30
|
||||
LUCKY_GAME_WINNING_COUNT=5
|
||||
EOF
|
||||
|
||||
log ".env файл создан успешно"
|
||||
}
|
||||
|
||||
# Создание службы systemd
|
||||
create_service() {
|
||||
while true; do
|
||||
read -p "Создать службу для запуска бота? (y/n): " create_service_choice
|
||||
case $create_service_choice in
|
||||
[Yy]*)
|
||||
log "Создание службы systemd..."
|
||||
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=RemnaWave Bedolaga Bot
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=$BOT_DIR
|
||||
ExecStart=/usr/bin/docker compose up -d
|
||||
ExecStop=/usr/bin/docker compose down
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable bedolaga-bot.service
|
||||
log "Служба bedolaga-bot создана и включена"
|
||||
break
|
||||
;;
|
||||
[Nn]*)
|
||||
log "Служба не будет создана"
|
||||
break
|
||||
;;
|
||||
*)
|
||||
error "Пожалуйста, ответьте y или n."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Проверка статуса бота
|
||||
check_bot_status() {
|
||||
if docker compose -f "$COMPOSE_FILE" ps --services --filter "status=running" | grep -q "bot"; then
|
||||
return 0 # Запущен
|
||||
else
|
||||
return 1 # Не запущен
|
||||
fi
|
||||
}
|
||||
|
||||
# Проверка подключения к RemnaWave API
|
||||
check_remnawave_connection() {
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
source "$ENV_FILE"
|
||||
if [ ! -z "$REMNAWAVE_URL" ]; then
|
||||
if curl -s --connect-timeout 5 "$REMNAWAVE_URL/api/auth/status" > /dev/null; then
|
||||
return 0 # Подключен
|
||||
else
|
||||
return 1 # Не подключен
|
||||
fi
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Запуск бота
|
||||
start_bot() {
|
||||
log "Запуск бота..."
|
||||
cd "$BOT_DIR"
|
||||
docker compose up -d
|
||||
log "Бот запущен"
|
||||
}
|
||||
|
||||
# Остановка бота
|
||||
stop_bot() {
|
||||
log "Остановка бота..."
|
||||
cd "$BOT_DIR"
|
||||
docker compose down
|
||||
log "Бот остановлен"
|
||||
}
|
||||
|
||||
# Перезапуск бота
|
||||
restart_bot() {
|
||||
log "Перезапуск бота..."
|
||||
cd "$BOT_DIR"
|
||||
docker compose restart
|
||||
log "Бот перезапущен"
|
||||
}
|
||||
|
||||
# Обновление бота
|
||||
update_bot() {
|
||||
log "Обновление бота..."
|
||||
cd "$BOT_DIR"
|
||||
docker compose down
|
||||
docker compose pull bot
|
||||
docker compose up -d
|
||||
log "Бот обновлен"
|
||||
}
|
||||
|
||||
# Просмотр логов
|
||||
view_logs() {
|
||||
cd "$BOT_DIR"
|
||||
docker compose logs bot
|
||||
}
|
||||
|
||||
# Просмотр логов в реальном времени
|
||||
view_live_logs() {
|
||||
cd "$BOT_DIR"
|
||||
docker compose logs -f bot
|
||||
}
|
||||
|
||||
# Создание резервной копии базы данных
|
||||
backup_database() {
|
||||
log "Создание резервной копии базы данных..."
|
||||
cd "$BOT_DIR"
|
||||
|
||||
# Проверяем, запущен ли контейнер postgres
|
||||
if ! docker compose ps postgres | grep -q "Up"; then
|
||||
log "Контейнер PostgreSQL не запущен. Запускаем PostgreSQL..."
|
||||
docker compose up -d postgres
|
||||
|
||||
# Ждем готовности базы данных
|
||||
log "Ожидание готовности базы данных..."
|
||||
for i in {1..30}; do
|
||||
if docker compose exec postgres pg_isready -U remnawave_user -d remnawave_bot &>/dev/null; then
|
||||
log "PostgreSQL готов к работе"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
error "PostgreSQL не запустился в течение 60 секунд"
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
echo -n "."
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Проверяем доступность базы данных
|
||||
log "Проверка подключения к базе данных..."
|
||||
if ! docker compose exec postgres pg_isready -U remnawave_user -d remnawave_bot &>/dev/null; then
|
||||
error "База данных недоступна. Проверьте логи: docker compose logs postgres"
|
||||
return 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$BOT_DIR/backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||
|
||||
log "Создание дампа базы данных..."
|
||||
|
||||
# Используем docker compose exec без -T и с правильным выводом
|
||||
if docker compose exec postgres pg_dump -U remnawave_user -d remnawave_bot --verbose --no-owner --no-privileges > "$BACKUP_FILE" 2>/dev/null; then
|
||||
# Проверяем размер созданного файла
|
||||
if [ -s "$BACKUP_FILE" ]; then
|
||||
FILE_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
log "Резервная копия создана успешно: $(basename "$BACKUP_FILE")"
|
||||
echo -e "${GREEN}Размер файла: $FILE_SIZE${NC}"
|
||||
echo -e "${GREEN}Путь: $BACKUP_FILE${NC}"
|
||||
|
||||
# Показываем краткую информацию о содержимом
|
||||
LINES_COUNT=$(wc -l < "$BACKUP_FILE")
|
||||
echo -e "${BLUE}Количество строк в дампе: $LINES_COUNT${NC}"
|
||||
else
|
||||
error "Резервная копия создана, но файл пустой. Возможные причины:"
|
||||
echo " - База данных пуста (бот еще не создал таблицы)"
|
||||
echo " - Нет прав доступа к базе данных"
|
||||
echo " - Неправильные параметры подключения"
|
||||
rm -f "$BACKUP_FILE"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
error "Ошибка при создании резервной копии"
|
||||
# Показываем подробную информацию об ошибке
|
||||
echo "Попытка диагностики проблемы..."
|
||||
docker compose exec postgres psql -U remnawave_user -d remnawave_bot -c "\dt" 2>&1 || {
|
||||
echo "Не удается подключиться к базе данных."
|
||||
echo "Проверьте логи контейнера: docker compose logs postgres"
|
||||
}
|
||||
rm -f "$BACKUP_FILE" 2>/dev/null
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Восстановление базы данных
|
||||
restore_database() {
|
||||
log "Восстановление базы данных из резервной копии"
|
||||
|
||||
# Поиск файлов резервных копий
|
||||
BACKUP_FILES=($(find "$BOT_DIR" -name "backup_*.sql" -type f 2>/dev/null))
|
||||
|
||||
if [ ${#BACKUP_FILES[@]} -eq 0 ]; then
|
||||
error "Файлы резервных копий не найдены в $BOT_DIR"
|
||||
echo "Поместите файл резервной копии (.sql) в папку $BOT_DIR"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Найденные резервные копии:${NC}"
|
||||
for i in "${!BACKUP_FILES[@]}"; do
|
||||
BACKUP_FILE="${BACKUP_FILES[$i]}"
|
||||
FILE_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
FILE_DATE=$(basename "$BACKUP_FILE" | sed 's/backup_\([0-9]\{8\}_[0-9]\{6\}\)\.sql/\1/' | sed 's/_/ /')
|
||||
echo "$((i+1))) $(basename "$BACKUP_FILE") - Размер: $FILE_SIZE - Дата: $FILE_DATE"
|
||||
done
|
||||
echo "$((${#BACKUP_FILES[@]}+1))) Указать путь к другому файлу"
|
||||
echo "0) Отмена"
|
||||
|
||||
while true; do
|
||||
read -p "Выберите файл для восстановления: " choice
|
||||
|
||||
if [ "$choice" = "0" ]; then
|
||||
log "Операция отменена"
|
||||
return 0
|
||||
elif [ "$choice" = "$((${#BACKUP_FILES[@]}+1))" ]; then
|
||||
read -p "Введите полный путь к файлу резервной копии: " SELECTED_BACKUP
|
||||
if [ ! -f "$SELECTED_BACKUP" ]; then
|
||||
error "Файл не найден: $SELECTED_BACKUP"
|
||||
continue
|
||||
fi
|
||||
break
|
||||
elif [ "$choice" -ge 1 ] && [ "$choice" -le "${#BACKUP_FILES[@]}" ]; then
|
||||
SELECTED_BACKUP="${BACKUP_FILES[$((choice-1))]}"
|
||||
break
|
||||
else
|
||||
error "Неверный выбор. Попробуйте снова."
|
||||
fi
|
||||
done
|
||||
|
||||
warn "ВНИМАНИЕ! Это действие перезапишет текущую базу данных!"
|
||||
warn "Убедитесь, что у вас есть резервная копия текущих данных!"
|
||||
read -p "Продолжить восстановление? Введите 'YES' для подтверждения: " confirm
|
||||
|
||||
if [ "$confirm" != "YES" ]; then
|
||||
log "Операция отменена"
|
||||
return 0
|
||||
fi
|
||||
|
||||
cd "$BOT_DIR"
|
||||
|
||||
# Проверяем, запущен ли контейнер postgres
|
||||
if ! docker compose ps postgres | grep -q "Up"; then
|
||||
log "Запуск контейнера PostgreSQL..."
|
||||
docker compose up -d postgres
|
||||
|
||||
# Ждем готовности базы данных
|
||||
log "Ожидание готовности базы данных..."
|
||||
for i in {1..30}; do
|
||||
if docker compose exec postgres pg_isready -U remnawave_user -d remnawave_bot &>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
echo -n "."
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
log "Очистка текущей базы данных..."
|
||||
if docker compose exec -T postgres psql -U remnawave_user -d remnawave_bot -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" &>/dev/null; then
|
||||
log "База данных очищена"
|
||||
else
|
||||
error "Ошибка при очистке базы данных"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Восстановление данных из файла: $(basename "$SELECTED_BACKUP")"
|
||||
if docker compose exec -T postgres psql -U remnawave_user -d remnawave_bot < "$SELECTED_BACKUP"; then
|
||||
log "База данных успешно восстановлена!"
|
||||
log "Перезапуск бота для применения изменений..."
|
||||
docker compose restart bot
|
||||
log "Восстановление завершено успешно"
|
||||
else
|
||||
error "Ошибка при восстановлении базы данных"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Диагностика базы данных
|
||||
diagnose_database() {
|
||||
log "Диагностика состояния базы данных..."
|
||||
cd "$BOT_DIR"
|
||||
|
||||
# Проверяем состояние контейнера
|
||||
echo -e "${YELLOW}Состояние контейнеров:${NC}"
|
||||
docker compose ps
|
||||
echo ""
|
||||
|
||||
# Проверяем, запущен ли PostgreSQL, если нет - запускаем
|
||||
if ! docker compose ps postgres | grep -q "Up"; then
|
||||
log "PostgreSQL не запущен. Запускаем..."
|
||||
docker compose up -d postgres
|
||||
|
||||
# Ждем готовности базы данных
|
||||
log "Ожидание готовности базы данных..."
|
||||
for i in {1..30}; do
|
||||
if docker compose exec postgres pg_isready -U remnawave_user -d remnawave_bot &>/dev/null; then
|
||||
log "PostgreSQL готов к работе"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
error "PostgreSQL не запустился в течение 60 секунд"
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
echo -n "."
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Проверяем доступность PostgreSQL
|
||||
echo -e "${YELLOW}Проверка доступности PostgreSQL:${NC}"
|
||||
if docker compose exec postgres pg_isready -U remnawave_user -d remnawave_bot; then
|
||||
echo -e "${GREEN}✓ PostgreSQL доступен${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ PostgreSQL недоступен${NC}"
|
||||
echo "Проверьте логи: docker compose logs postgres"
|
||||
return 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Проверяем подключение к базе данных
|
||||
echo -e "${YELLOW}Информация о базе данных:${NC}"
|
||||
docker compose exec postgres psql -U remnawave_user -d remnawave_bot -c "
|
||||
SELECT
|
||||
current_database() as database_name,
|
||||
current_user as current_user,
|
||||
version() as postgresql_version;
|
||||
" 2>/dev/null || {
|
||||
echo -e "${RED}Ошибка подключения к базе данных${NC}"
|
||||
return 1
|
||||
}
|
||||
echo ""
|
||||
|
||||
# Показываем список таблиц
|
||||
echo -e "${YELLOW}Таблицы в базе данных:${NC}"
|
||||
TABLES_OUTPUT=$(docker compose exec postgres psql -U remnawave_user -d remnawave_bot -c "\dt" 2>/dev/null)
|
||||
if echo "$TABLES_OUTPUT" | grep -q "No relations found"; then
|
||||
echo -e "${YELLOW}База данных пуста - таблицы еще не созданы${NC}"
|
||||
echo -e "${BLUE}Это нормально если бот еще ни разу не запускался${NC}"
|
||||
elif [ -z "$TABLES_OUTPUT" ]; then
|
||||
echo -e "${YELLOW}Не удается получить список таблиц${NC}"
|
||||
else
|
||||
echo "$TABLES_OUTPUT"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Показываем размер базы данных
|
||||
echo -e "${YELLOW}Размер базы данных:${NC}"
|
||||
docker compose exec postgres psql -U remnawave_user -d remnawave_bot -c "
|
||||
SELECT
|
||||
pg_database.datname,
|
||||
pg_size_pretty(pg_database_size(pg_database.datname)) AS size
|
||||
FROM pg_database
|
||||
WHERE datname = 'remnawave_bot';
|
||||
" 2>/dev/null || {
|
||||
echo -e "${RED}Не удается получить размер базы данных${NC}"
|
||||
}
|
||||
}
|
||||
edit_env_file() {
|
||||
ensure_nano
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
error "Файл .env не найден: $ENV_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Открытие .env файла для редактирования..."
|
||||
log "После изменений перезапустите бота для применения настроек"
|
||||
|
||||
# Создаем резервную копию .env файла
|
||||
cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
nano "$ENV_FILE"
|
||||
|
||||
log "Редактирование завершено"
|
||||
echo -e "${YELLOW}Не забудьте перезапустить бота для применения изменений!${NC}"
|
||||
}
|
||||
|
||||
# Удаление базы данных
|
||||
remove_database() {
|
||||
warn "ВНИМАНИЕ! Это действие удалит всю базу данных!"
|
||||
warn "Все данные бота (пользователи, подписки, настройки) будут потеряны!"
|
||||
read -p "Вы уверены? Введите 'YES' для подтверждения: " confirm
|
||||
if [ "$confirm" = "YES" ]; then
|
||||
log "Удаление базы данных..."
|
||||
cd "$BOT_DIR"
|
||||
|
||||
# Останавливаем все сервисы если они запущены
|
||||
if docker compose ps --services --filter "status=running" | grep -q "."; then
|
||||
log "Остановка всех контейнеров..."
|
||||
docker compose down
|
||||
fi
|
||||
|
||||
# Удаляем volume с данными PostgreSQL
|
||||
log "Удаление volume с данными PostgreSQL..."
|
||||
VOLUME_NAME=$(docker compose config --volumes 2>/dev/null | grep postgres || echo "bedolaga-bot_postgres_data")
|
||||
|
||||
# Пробуем удалить volume разными способами
|
||||
if docker volume ls | grep -q "$VOLUME_NAME"; then
|
||||
if docker volume rm "$VOLUME_NAME" 2>/dev/null; then
|
||||
log "Volume $VOLUME_NAME успешно удален"
|
||||
else
|
||||
log "Принудительное удаление volume..."
|
||||
docker volume rm "$VOLUME_NAME" --force 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Также пробуем удалить стандартное имя volume
|
||||
docker volume rm "$(basename $BOT_DIR)_postgres_data" 2>/dev/null || true
|
||||
|
||||
# Удаляем все неиспользуемые volumes связанные с проектом
|
||||
log "Очистка неиспользуемых volumes..."
|
||||
docker volume prune -f 2>/dev/null || true
|
||||
|
||||
log "База данных удалена"
|
||||
log "При следующем запуске бота будет создана новая пустая база данных"
|
||||
else
|
||||
log "Операция отменена"
|
||||
fi
|
||||
}
|
||||
|
||||
# Полное удаление бота
|
||||
remove_bot() {
|
||||
warn "ВНИМАНИЕ! Это действие полностью удалит бота и все данные!"
|
||||
read -p "Вы уверены? Введите 'YES' для подтверждения: " confirm
|
||||
if [ "$confirm" = "YES" ]; then
|
||||
log "Удаление бота..."
|
||||
cd "$BOT_DIR"
|
||||
docker compose down -v
|
||||
systemctl disable bedolaga-bot.service 2>/dev/null || true
|
||||
rm -f "$SERVICE_FILE"
|
||||
rm -rf "$BOT_DIR"
|
||||
log "Бот полностью удален"
|
||||
exit 0
|
||||
else
|
||||
log "Операция отменена"
|
||||
fi
|
||||
}
|
||||
|
||||
# Главное меню
|
||||
show_menu() {
|
||||
clear
|
||||
echo -e "${BLUE}=== RemnaWave Bedolaga Bot Management ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Показать статус бота
|
||||
echo -e "${YELLOW}Статус бота:${NC}"
|
||||
if check_bot_status; then
|
||||
echo -e "🟢 Бот: ${GREEN}ЗАПУЩЕН${NC}"
|
||||
else
|
||||
echo -e "🔴 Бот: ${RED}ОСТАНОВЛЕН${NC}"
|
||||
fi
|
||||
|
||||
# Показать статус подключения к API
|
||||
echo -e "${YELLOW}Подключение к RemnaWave API:${NC}"
|
||||
if check_remnawave_connection; then
|
||||
echo -e "🟢 API: ${GREEN}ПОДКЛЮЧЕН${NC}"
|
||||
else
|
||||
echo -e "🔴 API: ${RED}НЕ ПОДКЛЮЧЕН${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Доступные действия:${NC}"
|
||||
|
||||
if check_bot_status; then
|
||||
# Бот запущен
|
||||
echo "1) Выключить бот"
|
||||
echo "2) Перезапустить бот"
|
||||
echo "3) Посмотреть логи в реальном времени"
|
||||
echo "4) Обновить бот"
|
||||
echo "5) Посмотреть логи"
|
||||
echo "6) Создать резервную копию БД"
|
||||
echo "7) Восстановить базу данных"
|
||||
echo "8) Редактировать .env файл"
|
||||
echo "9) Диагностика базы данных"
|
||||
echo "10) Удалить базу данных"
|
||||
echo "11) Полностью удалить бота"
|
||||
echo "0) Выход"
|
||||
|
||||
read -p "Выберите действие: " choice
|
||||
case $choice in
|
||||
1) stop_bot; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
2) restart_bot; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
3) view_live_logs; ;;
|
||||
4) update_bot; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
5) view_logs; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
6) backup_database; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
7) restore_database; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
8) edit_env_file; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
9) diagnose_database; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
10) remove_database; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
11) remove_bot; ;;
|
||||
0) exit 0; ;;
|
||||
*) error "Неверный выбор"; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
esac
|
||||
else
|
||||
# Бот остановлен
|
||||
echo "1) Запустить бот"
|
||||
echo "2) Обновить бот"
|
||||
echo "3) Посмотреть логи"
|
||||
echo "4) Создать резервную копию БД"
|
||||
echo "5) Восстановить базу данных"
|
||||
echo "6) Редактировать .env файл"
|
||||
echo "7) Диагностика базы данных"
|
||||
echo "8) Удалить базу данных"
|
||||
echo "9) Полностью удалить бота"
|
||||
echo "0) Выход"
|
||||
|
||||
read -p "Выберите действие: " choice
|
||||
case $choice in
|
||||
1) start_bot; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
2) update_bot; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
3) view_logs; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
4) backup_database; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
5) restore_database; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
6) edit_env_file; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
7) diagnose_database; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
8) remove_database; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
9) remove_bot; ;;
|
||||
0) exit 0; ;;
|
||||
*) error "Неверный выбор"; read -p "Нажмите Enter для продолжения..."; ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# Основная функция установки
|
||||
install_bot() {
|
||||
log "Начало установки RemnaWave Bedolaga Bot"
|
||||
|
||||
update_system
|
||||
install_docker
|
||||
create_project_structure
|
||||
create_docker_compose
|
||||
create_env_file
|
||||
create_service
|
||||
|
||||
log "Установка завершена!"
|
||||
log "Бот установлен в: $BOT_DIR"
|
||||
log "Для управления ботом используйте это меню или systemctl"
|
||||
|
||||
read -p "Нажмите Enter для перехода в меню управления..."
|
||||
}
|
||||
|
||||
# Главная функция
|
||||
main() {
|
||||
check_root
|
||||
|
||||
if check_installation; then
|
||||
log "Бот уже установлен. Переход в меню управления..."
|
||||
ensure_nano # Убеждаемся что nano установлен
|
||||
else
|
||||
install_bot
|
||||
fi
|
||||
|
||||
# Основной цикл меню
|
||||
while true; do
|
||||
show_menu
|
||||
done
|
||||
}
|
||||
|
||||
# Запуск скрипта
|
||||
main "$@"
|
||||
@@ -1,139 +0,0 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
BOT_TOKEN: str
|
||||
BOT_USERNAME: str
|
||||
DATABASE_URL: str
|
||||
REMNAWAVE_URL: str
|
||||
REMNAWAVE_TOKEN: str
|
||||
SUBSCRIPTION_BASE_URL: str
|
||||
ADMIN_IDS: List[int]
|
||||
SUPPORT_USERNAME: str
|
||||
DEFAULT_LANGUAGE: str
|
||||
TRIAL_ENABLED: bool
|
||||
TRIAL_DURATION_DAYS: int
|
||||
TRIAL_TRAFFIC_GB: int
|
||||
TRIAL_SQUAD_UUID: str
|
||||
REFERRAL_FIRST_REWARD: float
|
||||
REFERRAL_REFERRED_BONUS: float
|
||||
REFERRAL_THRESHOLD: float
|
||||
REFERRAL_PERCENTAGE: float
|
||||
MONITOR_ENABLED: bool = True
|
||||
MONITOR_CHECK_INTERVAL: int = 3600
|
||||
MONITOR_DAILY_CHECK_HOUR: int = 10
|
||||
MONITOR_WARNING_DAYS: int = 3
|
||||
DELETE_EXPIRED_TRIAL_DAYS: int = 1
|
||||
DELETE_EXPIRED_REGULAR_DAYS: int = 7
|
||||
AUTO_DELETE_ENABLED: bool = False
|
||||
|
||||
LUCKY_GAME_ENABLED: bool = True
|
||||
LUCKY_GAME_REWARD: float = 50.0
|
||||
LUCKY_GAME_NUMBERS: int = 30
|
||||
LUCKY_GAME_WINNING_COUNT: int = 3
|
||||
|
||||
STARS_ENABLED: bool = True
|
||||
STARS_RATES: Dict[int, float] = None
|
||||
|
||||
def load_config() -> Config:
|
||||
|
||||
def parse_admin_ids(admin_ids_str: str) -> List[int]:
|
||||
if not admin_ids_str:
|
||||
return []
|
||||
try:
|
||||
return [int(id_str.strip()) for id_str in admin_ids_str.split(',') if id_str.strip()]
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
def get_bool(key: str, default: bool = False) -> bool:
|
||||
value = os.getenv(key, '').lower()
|
||||
return value in ('true', '1', 'yes', 'on')
|
||||
|
||||
def get_int(key: str, default: int = 0) -> int:
|
||||
try:
|
||||
return int(os.getenv(key, str(default)))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def get_float(key: str, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(os.getenv(key, str(default)))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def parse_stars_rates() -> Dict[int, float]:
|
||||
default_rates = {
|
||||
100: 150.0,
|
||||
150: 220.0,
|
||||
250: 400.0,
|
||||
350: 500.0,
|
||||
500: 800.0,
|
||||
750: 1150.0,
|
||||
1000: 1500.0
|
||||
}
|
||||
|
||||
custom_rates = {}
|
||||
|
||||
for stars in [100, 150, 250, 350, 500, 750, 1000]:
|
||||
env_key = f"STARS_{stars}_RATE"
|
||||
rate = get_float(env_key, 0.0)
|
||||
if rate > 0:
|
||||
custom_rates[stars] = rate
|
||||
|
||||
if custom_rates:
|
||||
return custom_rates
|
||||
|
||||
return default_rates
|
||||
|
||||
return Config(
|
||||
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
|
||||
BOT_USERNAME=os.getenv('BOT_USERNAME', ''),
|
||||
DATABASE_URL=os.getenv('DATABASE_URL', 'sqlite+aiosqlite:///bot.db'),
|
||||
REMNAWAVE_URL=os.getenv('REMNAWAVE_URL', ''),
|
||||
REMNAWAVE_TOKEN=os.getenv('REMNAWAVE_TOKEN', ''),
|
||||
SUBSCRIPTION_BASE_URL=os.getenv('SUBSCRIPTION_BASE_URL', ''),
|
||||
ADMIN_IDS=parse_admin_ids(os.getenv('ADMIN_IDS', '')),
|
||||
SUPPORT_USERNAME=os.getenv('SUPPORT_USERNAME', 'support'),
|
||||
DEFAULT_LANGUAGE=os.getenv('DEFAULT_LANGUAGE', 'ru'),
|
||||
TRIAL_ENABLED=get_bool('TRIAL_ENABLED', False),
|
||||
TRIAL_DURATION_DAYS=get_int('TRIAL_DURATION_DAYS', 3),
|
||||
TRIAL_TRAFFIC_GB=get_int('TRIAL_TRAFFIC_GB', 2),
|
||||
TRIAL_SQUAD_UUID=os.getenv('TRIAL_SQUAD_UUID', ''),
|
||||
REFERRAL_FIRST_REWARD=get_float('REFERRAL_FIRST_REWARD', 150.0),
|
||||
REFERRAL_REFERRED_BONUS=get_float('REFERRAL_REFERRED_BONUS', 150.0),
|
||||
REFERRAL_THRESHOLD=get_float('REFERRAL_THRESHOLD', 300.0),
|
||||
REFERRAL_PERCENTAGE=get_float('REFERRAL_PERCENTAGE', 0.25),
|
||||
MONITOR_ENABLED=get_bool('MONITOR_ENABLED', True),
|
||||
MONITOR_CHECK_INTERVAL=get_int('MONITOR_CHECK_INTERVAL', 3600),
|
||||
MONITOR_DAILY_CHECK_HOUR=get_int('MONITOR_DAILY_CHECK_HOUR', 10),
|
||||
MONITOR_WARNING_DAYS=get_int('MONITOR_WARNING_DAYS', 3),
|
||||
DELETE_EXPIRED_TRIAL_DAYS=get_int('DELETE_EXPIRED_TRIAL_DAYS', 1),
|
||||
DELETE_EXPIRED_REGULAR_DAYS=get_int('DELETE_EXPIRED_REGULAR_DAYS', 7),
|
||||
AUTO_DELETE_ENABLED=get_bool('AUTO_DELETE_ENABLED', False),
|
||||
LUCKY_GAME_ENABLED=get_bool('LUCKY_GAME_ENABLED', True),
|
||||
LUCKY_GAME_REWARD=get_float('LUCKY_GAME_REWARD', 50.0),
|
||||
LUCKY_GAME_NUMBERS=get_int('LUCKY_GAME_NUMBERS', 30),
|
||||
LUCKY_GAME_WINNING_COUNT=get_int('LUCKY_GAME_WINNING_COUNT', 3),
|
||||
STARS_ENABLED=get_bool('STARS_ENABLED', True),
|
||||
STARS_RATES=parse_stars_rates()
|
||||
)
|
||||
|
||||
def debug_environment():
|
||||
env_vars = [
|
||||
'BOT_TOKEN', 'BOT_USERNAME', 'DATABASE_URL',
|
||||
'REMNAWAVE_URL', 'REMNAWAVE_TOKEN', 'ADMIN_IDS',
|
||||
'REFERRAL_FIRST_REWARD', 'REFERRAL_THRESHOLD',
|
||||
'MONITOR_ENABLED', 'MONITOR_CHECK_INTERVAL',
|
||||
'DELETE_EXPIRED_TRIAL_DAYS', 'DELETE_EXPIRED_REGULAR_DAYS', 'AUTO_DELETE_ENABLED',
|
||||
'LUCKY_GAME_ENABLED', 'LUCKY_GAME_REWARD',
|
||||
'STARS_ENABLED', 'STARS_100_RATE', 'STARS_500_RATE', 'STARS_1000_RATE'
|
||||
]
|
||||
|
||||
print("📋 Environment variables:")
|
||||
for var in env_vars:
|
||||
value = os.getenv(var, 'NOT SET')
|
||||
if 'TOKEN' in var and value != 'NOT SET':
|
||||
value = value[:10] + "..." if len(value) > 10 else value
|
||||
print(f" {var}: {value}")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user