Compare commits
1119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a953479ebf | |||
| db21914afe | |||
| bbc907ef93 | |||
| 084b8a3a08 | |||
| e7e7de8115 | |||
| a65c38394f | |||
| 82ad2ae8e9 | |||
| 3e251a46ea | |||
| c76c8d6544 | |||
| d61c0dcb30 | |||
| e2d31525ea | |||
| 4dda834805 | |||
| 6a46f7e7ff | |||
| 05ee8ff1f4 | |||
| 9f696fa9cd | |||
| 9666947125 | |||
| 0f6d09c06c | |||
| f1e3cb0c05 | |||
| 20a4c0ab38 | |||
| eef645fbcf | |||
| e00588d468 | |||
| 5ab21c9e8d | |||
| cb45a4d760 | |||
| 7d8d874390 | |||
| 36107a32c7 | |||
| c0dc3dcc18 | |||
| df43ea2716 | |||
| 43af9df078 | |||
| 31b9d0abdf | |||
| 572aa88ee1 | |||
| 3dbec2196a | |||
| 9a5cc083ca | |||
| 2172aa02a8 | |||
| 4316bf2209 | |||
| 331d140b42 | |||
| e67dce82d6 | |||
| b442e70657 | |||
| 4fb02063fd | |||
| 96fb83f7f4 | |||
| cc3b8a0571 | |||
| 32fda4e8b5 | |||
| cd903944fa | |||
| 3ab1007178 | |||
| 0e820f62c9 | |||
| 3e33513fe9 | |||
| daa9710579 | |||
| 6b2f4f6ac5 | |||
| 8e44c9755b | |||
| d55fbf0fed | |||
| e05f8f8183 | |||
| 1ae19cd351 | |||
| d79deb307e | |||
| 8f494c0b98 | |||
| 71803174b0 | |||
| 1c660320da | |||
| 014dbd7d73 | |||
| 22e0d610a7 | |||
| d4f77df197 | |||
| 0f7a9e58d8 | |||
| 9ae2de2eb5 | |||
| 1309c3ef43 | |||
| 0edadd9592 | |||
| cc403fac67 | |||
| faecb7fb13 | |||
| 32cc047943 | |||
| d4d2ff1867 | |||
| d2d6ba1be9 | |||
| 96905c0f51 | |||
| fe2c70ab75 | |||
| 1174a3ff34 | |||
| e66a465eee | |||
| d6ec510055 | |||
| 29bd962d49 | |||
| 8fb8e203ae | |||
| 78cfd35ad8 | |||
| e6429fda9d | |||
| d491178410 | |||
| b3b21d1f5f | |||
| e8030c1db9 | |||
| a1f0449e5c | |||
| fd3e26dd47 | |||
| ae39b6df9a | |||
| cbd2f58499 | |||
| 8ea78943fe | |||
| 8bd904af6c | |||
| b3369d8708 | |||
| 4af368549e | |||
| 07bbd9b177 | |||
| 11898a5d13 | |||
| e662e0d06e | |||
| dc2e1158fa | |||
| 109df2354c | |||
| 4d337e3df5 | |||
| 9a042c638a | |||
| d520f02c03 | |||
| 0581327358 | |||
| 0883cf2bf6 | |||
| 6067eca053 | |||
| 48d7e197a1 | |||
| 961514d712 | |||
| 97bfc1bc43 | |||
| b3b15db8c0 | |||
| ce275babc3 | |||
| 3aac6b9872 | |||
| 8813664f3f | |||
| ebd50f8b3d | |||
| 62fccf2b10 | |||
| 943027f17f | |||
| ea0a3dc962 | |||
| 2d2a11e5b6 | |||
| 55fe1342cf | |||
| 105f798c26 | |||
| 944295165d | |||
| 18be0049cc | |||
| 26a32b6f07 | |||
| f477094614 | |||
| 367331d113 | |||
| 51d40323e5 | |||
| 5ddf7f8242 | |||
| e4a73f9aaa | |||
| b99c090a84 | |||
| 15ad1a0f79 | |||
| 344e4757ef | |||
| 55461aa50b | |||
| 24928fe561 | |||
| f16084b941 | |||
| caeb531445 | |||
| fefbf4da39 | |||
| cd3c19b79d | |||
| 6cd1dd8bc2 | |||
| 3837f47bb6 | |||
| bff7350224 | |||
| cd4c526da0 | |||
| 38e931446a | |||
| 55af3bde3d | |||
| 6bca31f92c | |||
| f8858e8561 | |||
| d3c729cdc2 | |||
| 4c954409cd | |||
| b2dbdb60b6 | |||
| a26d334653 | |||
| ef1cf1fe26 | |||
| 1356170fb0 | |||
| 998cf9a6e1 | |||
| 85a6be831e | |||
| 9df5d8469d | |||
| 3133cc7799 | |||
| 7b894300b7 | |||
| 2621d29ef0 | |||
| 76ad4e5821 | |||
| 0056454375 | |||
| 3c15589b03 | |||
| 4849c899c2 | |||
| 44b9986a45 | |||
| a9359e9dc0 | |||
| 76e730475d | |||
| 18d3b4246a | |||
| 22121b9171 | |||
| ff498feb7e | |||
| 15fcc0ac70 | |||
| b1c73dc1a6 | |||
| 888812babd | |||
| 023cd1728d | |||
| abbd3474b4 | |||
| fa17faa424 | |||
| 799c657605 | |||
| fa875fb3bc | |||
| cc628a08fa | |||
| fa85fb70f1 | |||
| 1d5980d725 | |||
| 9474ccfb06 | |||
| c6e91c0543 | |||
| 593206f6a0 | |||
| ea6f5297c1 | |||
| 471714b80d | |||
| a92a19b84d | |||
| 289e0ae5b5 | |||
| d08b410888 | |||
| 455455d696 | |||
| 57ad26711d | |||
| 24428a030e | |||
| c42fee4298 | |||
| aba9620c0f | |||
| dd8c87f887 | |||
| eebc2ee1d5 | |||
| 7bd5ecc55f | |||
| 5d25e211df | |||
| 0887e28b2b | |||
| e04be209cb | |||
| d19eee8897 | |||
| 3efdeed00c | |||
| 9e89a2db10 | |||
| 59a23ea9bd | |||
| 216c8f8d81 | |||
| d0f6511c5c | |||
| e1ecb6a1dc | |||
| 99c02874ec | |||
| 0cd896d35e | |||
| ab35ce9178 | |||
| f71e6eb226 | |||
| cd3b55271a | |||
| e71de5f3cf | |||
| 0fc2c535de | |||
| aef1eb2861 | |||
| b57d79d65c | |||
| d9533e673e | |||
| 3e452c4c1b | |||
| 31582080c6 | |||
| d85dad2a9e | |||
| 7f15e208e1 | |||
| ae291e2ae7 | |||
| 7fd15da4a6 | |||
| 4fdbaca7e8 | |||
| 54180c6b28 | |||
| b7db9be638 | |||
| 76d6476ea7 | |||
| 70867dccec | |||
| dd7144cd5b | |||
| 29f54a408d | |||
| bff7d3ede0 | |||
| 2a5a254cae | |||
| f7db7f445b | |||
| ec9016f958 | |||
| d73a765866 | |||
| eb70e38a02 | |||
| b587cf527e | |||
| a8ce3f65ee | |||
| 5200e25e88 | |||
| e676213102 | |||
| 1f56820ce5 | |||
| 58937a405d | |||
| b7d13576b8 | |||
| 0828bca2ed | |||
| c1dc10e5ed | |||
| 50eba2d093 | |||
| ea6d9d300f | |||
| a06ee6f395 | |||
| 86e678de08 | |||
| 772a677424 | |||
| cccb963da2 | |||
| 2b3ce6ce55 | |||
| 1c119b1d7a | |||
| 4043380411 | |||
| c3d71bdc28 | |||
| dc6f73c539 | |||
| 476a8cb842 | |||
| bc81c64cbe | |||
| 812264722a | |||
| 0d06f61d40 | |||
| 2634facd51 | |||
| a92b6d1e86 | |||
| 4cfbcd8590 | |||
| 73ba081779 | |||
| 3aa6c26940 | |||
| dc8ff4a007 | |||
| 6390a18327 | |||
| a5d9dad826 | |||
| 06e6b30d31 | |||
| f9e404a7c2 | |||
| 3920992cee | |||
| fcb6dd9773 | |||
| 6d42647bf6 | |||
| 6e3b33e335 | |||
| 063ade1a2a | |||
| 56ffbd654e | |||
| 47777c7ee8 | |||
| 27d021171e | |||
| d4b00fb494 | |||
| 7816fa535c | |||
| 0208c48212 | |||
| 4295e4b5ec | |||
| 4fb13a089a | |||
| f1841caea7 | |||
| 4a67f9233d | |||
| e55a89af8e | |||
| 1f172f1a21 | |||
| 3f7bcb090a | |||
| a3b049dc1f | |||
| bf2d225c55 | |||
| 33b15b8486 | |||
| 19a273baf4 | |||
| 1c205f4a24 | |||
| f2a47de5da | |||
| 372933e230 | |||
| 11236ac64e | |||
| 46f71a7a6e | |||
| 70293fe387 | |||
| c7915a45b8 | |||
| 8b016c7010 | |||
| 17032811a4 | |||
| ff591fc387 | |||
| ece35bb90a | |||
| dfec3c5748 | |||
| 29ecb61ad2 | |||
| 79fd3cde5d | |||
| 5f4a23e808 | |||
| 3d10e2c1fe | |||
| 6b8507253e | |||
| 90d9c0bc54 | |||
| 71301f90ae | |||
| 472a3ad69d | |||
| 5cd63bc89d | |||
| 77bd87a2b5 | |||
| 53483ce6aa | |||
| a9e546091a | |||
| 924b5e15c5 | |||
| ae9b5b1bf7 | |||
| adb669fa9e | |||
| 2daf2fadc8 | |||
| c9b0cd2db1 | |||
| 098459f3ce | |||
| 64d25967c9 | |||
| 5804a8e87a | |||
| 388fbb5afc | |||
| d3e502ce43 | |||
| af497f644a | |||
| e57680f3a7 | |||
| c7eff5cc3e | |||
| 3ccbf00259 | |||
| 6066775cf4 | |||
| 92ec071e6b | |||
| 8e070349ba | |||
| 80360691f2 | |||
| 2f0c22abe8 | |||
| 9c7cbe403b | |||
| fcdfd3a484 | |||
| 8f1ceb4e66 | |||
| 2db29fba9d | |||
| d0125c1f33 | |||
| 0dba6a6e42 | |||
| 02fbf89d09 | |||
| 305fbde885 | |||
| 3d94037d4e | |||
| 4a5aae06c3 | |||
| 19e476aa3f | |||
| 5c62ed8d92 | |||
| f138572b38 | |||
| f907906d04 | |||
| 097ce51b2c | |||
| 1fa553f246 | |||
| 32c65b3194 | |||
| a4d11f67fa | |||
| 1775d90bab | |||
| 1d6ae5fa07 | |||
| fa2d3eaa0a | |||
| 148e99ea6f | |||
| 64f99ac660 | |||
| bded99263a | |||
| aded5606a8 | |||
| 79982f5aa6 | |||
| dc27a6e69b | |||
| 8af59ffbfe | |||
| 013b26ecc8 | |||
| 7df831c084 | |||
| 507e0b5e1d | |||
| f2a12b8d98 | |||
| 1d089cce7d | |||
| ba89c054fd | |||
| 33801f58c4 | |||
| a963f4ad12 | |||
| b8472ee428 | |||
| 769f5eaf9a | |||
| c9e791fa76 | |||
| a32d9d422f | |||
| 39baa1640c | |||
| 37ebf4cdee | |||
| bb0085a438 | |||
| b05e57fb16 | |||
| 4c977d07ce | |||
| bd39825cf9 | |||
| e01a7efe4a | |||
| 8b159ab75d | |||
| 316bd191a2 | |||
| 29f6fe40e0 | |||
| 40f1f1b14e | |||
| 31822ee634 | |||
| 773db02746 | |||
| 2ba9615151 | |||
| 19c4c0496a | |||
| 903cde9a78 | |||
| 3592eccea9 | |||
| 4afd1668c9 | |||
| f799dd8766 | |||
| 254c489010 | |||
| 53bc6f6167 | |||
| b48cb94b12 | |||
| 0813ea2897 | |||
| 770f40f4ff | |||
| dba8752436 | |||
| 4b503ce31a | |||
| 8b9c00f53e | |||
| 55811aff56 | |||
| 310aa9bce6 | |||
| 2dcd1e65db | |||
| 4f840c171a | |||
| bfefdef304 | |||
| 3de049e436 | |||
| 74bb6b3f6d | |||
| 56e6dc3ae6 | |||
| c34626aec0 | |||
| d4187149b8 | |||
| e982a5b718 | |||
| a9a17bd693 | |||
| aef1973720 | |||
| 5a77f29bd5 | |||
| 0789b3fe9d | |||
| c82f64980b | |||
| 7623f339bc | |||
| 3de1635efa | |||
| eb824162d2 | |||
| 54d5df72af | |||
| 74c991cf85 | |||
| 46e6102412 | |||
| 698b8a9484 | |||
| 94f3c00628 | |||
| 630cf61f84 | |||
| d3b384aa61 | |||
| d209cf11c7 | |||
| 49eb0e68a1 | |||
| 3e7bebf189 | |||
| 73fbc22324 | |||
| 9774db74e5 | |||
| 82d8b50956 | |||
| 587bf76c61 | |||
| c2e4719f18 | |||
| 7b4bddfa28 | |||
| 8be57829fb | |||
| ceb3e160da | |||
| 007f3a7bcf | |||
| cc42900142 | |||
| 1009bb4001 | |||
| e4727dd0a4 | |||
| 3d6acf51f6 | |||
| 1634588be9 | |||
| c379011fd2 | |||
| 8483ef1df0 | |||
| 9be291c1e8 | |||
| 6871f2d15e | |||
| 60c9a4f1e6 | |||
| 5a894e0e10 | |||
| 45b9c56282 | |||
| 88c4d940e8 | |||
| 3741e4f3bf | |||
| 28e7c78601 | |||
| 37fbd35e8f | |||
| c41b77d365 | |||
| 1d19d8b744 | |||
| b04bc4dca5 | |||
| 4dd4d829d7 | |||
| bf35156455 | |||
| e6a1b55045 | |||
| 455ab76c8a | |||
| 720c145aa3 | |||
| 580ce90442 | |||
| 239426f6d5 | |||
| 3c4ae34997 | |||
| 899da825cb | |||
| 59e0d965da | |||
| b70e27932d | |||
| 69dcef073e | |||
| 89394ef630 | |||
| 79a9a95ffc | |||
| ee9e65d692 | |||
| 9bc135a51a | |||
| fabdfc6ab6 | |||
| dff378ad81 | |||
| b1739cf4ea | |||
| d47952fe3e | |||
| 74d58ce310 | |||
| 5f43bb6e55 | |||
| f7c906f608 | |||
| e97e3060f7 | |||
| b6ebfd5c1e | |||
| 3d67311e61 | |||
| 7bfbf89054 | |||
| 43959a996e | |||
| 591370ce60 | |||
| bb803ab145 | |||
| 07d14a301b | |||
| 56af0a7885 | |||
| c2ba4e9ab6 | |||
| 7b372e095e | |||
| 33f98276ce | |||
| 8818d91232 | |||
| 0aab2096a5 | |||
| 5a3f54c3a7 | |||
| 79ebe6b6bc | |||
| 6c323dc7df | |||
| 94980d4524 | |||
| 78c00b5454 | |||
| 9437ee9c90 | |||
| 4addb772a0 | |||
| a1d051a3f1 | |||
| c4d3c921bd | |||
| 1596c85bee | |||
| 3d4a72d3fd | |||
| ad9c49f841 | |||
| cc2b9b52c5 | |||
| 5a1d4c1b24 | |||
| 99cc4b26bc | |||
| 970511ca1d | |||
| b81a0f289f | |||
| 13259b435b | |||
| 44cadc29ba | |||
| a2c71a49f2 | |||
| e7d54ae317 | |||
| 1459bdacfa | |||
| a9e111bd4f | |||
| b63c3fb115 | |||
| a0a6686058 | |||
| 4c4a1a297c | |||
| fc4bb88db8 | |||
| e17cde7c72 | |||
| 3d7684b72b | |||
| 51558aeeda | |||
| d83da5daaa | |||
| 67b2da3447 | |||
| eeb330b21f | |||
| efff92b2b3 | |||
| 4d5cc954b5 | |||
| 1e204a3e29 | |||
| 98d0c03aee | |||
| 1a29bb6dd5 | |||
| 0959742f5b | |||
| 1c505b74d5 | |||
| 96a6612124 | |||
| 78181e7230 | |||
| 66bba43ec4 | |||
| 4140fbae78 | |||
| e4a4d68758 | |||
| 478d2ee0e6 | |||
| 588c32453d | |||
| ce6186fa17 | |||
| a617b940c1 | |||
| c370d32154 | |||
| 89f329458c | |||
| 4df9140cf8 | |||
| 8e74c12bca | |||
| 4e280d1b3e | |||
| c158c1339f | |||
| 9db8fdf79b | |||
| b6184d4c90 | |||
| 981dc0db01 | |||
| 0403ad5688 | |||
| f05b7416ef | |||
| 1d433d2cfd | |||
| 17a0fa0b21 | |||
| 7154374fdc | |||
| 34b1927c22 | |||
| 1e9367ab93 | |||
| a7d0cca66c | |||
| e3bb7f6663 | |||
| 450b8e17f0 | |||
| f176a11ff9 | |||
| aadd2edf45 | |||
| a02062a4b4 | |||
| 0092764f8f | |||
| 55b065f784 | |||
| eafe0a78d2 | |||
| 9da808a024 | |||
| 3d295743e2 | |||
| 6f8bc2b08d | |||
| 32d0ac81bd | |||
| 35f214a45f | |||
| 8fdc78c2e8 | |||
| 6943794e50 | |||
| 086e3fe5b0 | |||
| d247b764f7 | |||
| 283016f043 | |||
| 7c19492c05 | |||
| 054d710d6c | |||
| f11b014acf | |||
| ab27328cb7 | |||
| 9b98faedfd | |||
| e957fbdfa1 | |||
| 94273b5ee2 | |||
| 87d92b7dae | |||
| 9a823eddf3 | |||
| 52d124c879 | |||
| 287d0a635b | |||
| 1057fd6ef6 | |||
| d935a9cd49 | |||
| 97d598a293 | |||
| c9664c136b | |||
| 0b0fca8648 | |||
| 9577c8272f | |||
| 0d82294bdb | |||
| d40c845d62 | |||
| 70e3f5c714 | |||
| 8a15d0320c | |||
| 73bf682f97 | |||
| ba53e9e199 | |||
| e13937b965 | |||
| 7758415ddf | |||
| a19c325e15 | |||
| 10df771a15 | |||
| 6cd7e8acee | |||
| d8944a9c52 | |||
| 930ce0fcdc | |||
| fceff14d0a | |||
| 16e31d4abb | |||
| 5289548fd9 | |||
| 681fd7d92c | |||
| 932ef51de4 | |||
| 080a99cd55 | |||
| 93ac01592f | |||
| 1742519659 | |||
| 954c6b6ae7 | |||
| 50811afa30 | |||
| afc913a2ff | |||
| bab6a807f3 | |||
| 6329dfd3ef | |||
| 6484bf2c6e | |||
| 1e64817e3f | |||
| 15fef698d9 | |||
| c108ec4b2d | |||
| 5d70576582 | |||
| 177cbb8881 | |||
| 7d8050ce1c | |||
| fadafecb43 | |||
| ab6af88114 | |||
| 3b7bb5436b | |||
| bfe8ed8b3c | |||
| 95b338a1c2 | |||
| 072a90a028 | |||
| 933c8100e4 | |||
| 4951a5cf78 | |||
| 5e904f3045 | |||
| 0840a07ed9 | |||
| 2becee7f02 | |||
| ae22950ce3 | |||
| 169b276ef1 | |||
| 5966555ae9 | |||
| de27fa24d4 | |||
| 0b0b002c82 | |||
| 0e4f59a242 | |||
| 051714e613 | |||
| c780b1ffd7 | |||
| 88caa0bab3 | |||
| e6b071f64d | |||
| 9a809aa646 | |||
| 52bdf10e0e | |||
| 1642bde410 | |||
| 2fdb29d517 | |||
| ad8d28f7f0 | |||
| a4776d7682 | |||
| 3c7451e8fc | |||
| f65ed3eada | |||
| b4c47faac9 | |||
| 217bb457a1 | |||
| ce79caa58d | |||
| 76d72ba203 | |||
| 035ef47619 | |||
| 4792abc6d5 | |||
| 42ac740589 | |||
| d5371e4c04 | |||
| 7c883b07f9 | |||
| fa681fac92 | |||
| d1a9691c87 | |||
| b31c9d2e10 | |||
| 11176a16bc | |||
| 39aaa88532 | |||
| cf6fcdbb76 | |||
| 4e09e04dd9 | |||
| e0639c7116 | |||
| 1d915f849c | |||
| 404d502d2f | |||
| bcfb4c7942 | |||
| bd5cc56441 | |||
| bc42b0e52b | |||
| 71e549866b | |||
| ca9dd17450 | |||
| 91f12fe627 | |||
| 1406e87ab3 | |||
| 9398199f25 | |||
| 6ed83097de | |||
| 4d38ab0d3a | |||
| d955a6ccfc | |||
| 82da0cbf53 | |||
| 360d73dd26 | |||
| 14400018c9 | |||
| f845baa3d0 | |||
| 54e6c2aaaa | |||
| 240ad42beb | |||
| a8d8972855 | |||
| b70b432d05 | |||
| 77d6b8c67c | |||
| 9251087d2c | |||
| 5f2f3ad683 | |||
| 0f507aa101 | |||
| 99574ce751 | |||
| 441e7ef696 | |||
| 8395c29ae2 | |||
| ff37c48579 | |||
| 8e778eb7af | |||
| 2f6b85d4a6 | |||
| bb034ba6c6 | |||
| d86acbfb9d | |||
| baf0e2d695 | |||
| b58079d86f | |||
| ee2ae3f14d | |||
| 5420c38592 | |||
| 2a3d2098c9 | |||
| 36a44f3669 | |||
| ccbe82a140 | |||
| 8b6afcf571 | |||
| 74353143cf | |||
| 7f48f5b7b5 | |||
| 5bb83fe904 | |||
| bfdf5f9f41 | |||
| d7d0e3da42 | |||
| 11c84eae82 | |||
| cd7db1330e | |||
| 24c1f8b9cb | |||
| 8c62573664 | |||
| fc1dda5597 | |||
| 9e899f4ec6 | |||
| bb2fea1396 | |||
| 9a05ff82e4 | |||
| 21e82eed79 | |||
| ca1a279144 | |||
| 4e6eb4ea7f | |||
| e3787a041c | |||
| cd411c6b49 | |||
| 9acc9d8304 | |||
| cb19c3829f | |||
| b42cc6b484 | |||
| 4cf1f5dad8 | |||
| d863eb1ed9 | |||
| cbd0902e42 | |||
| fa2b45104c | |||
| bac2dbaf85 | |||
| f9e29f4fa9 | |||
| 82985aa96e | |||
| d34f49c9b2 | |||
| bbe355008a | |||
| ae115ba13b | |||
| dc7da4441a | |||
| d91465c435 | |||
| 9cb9b9441a | |||
| ac3a4a397f | |||
| 995964f70a | |||
| 708b0be31f | |||
| 052124d483 | |||
| b40bf3f833 | |||
| 11d7005954 | |||
| 7893be6ef8 | |||
| 18ca2b275c | |||
| bc333b12c2 | |||
| c3e052f73c | |||
| 29f606736b | |||
| ce17181be5 | |||
| 041c8e1511 | |||
| 270f01511d | |||
| a352901d85 | |||
| 8e5ce5768a | |||
| 97c03d0396 | |||
| 1b9b3e6729 | |||
| 3f336919ad | |||
| 8f8e89667d | |||
| 88b05547de | |||
| c35d067306 | |||
| 06489967f8 | |||
| 47e45cf596 | |||
| bc77c6a5ff | |||
| 31eb551b6d | |||
| 122b82253e | |||
| 5c79302cd6 | |||
| 34a99ceb98 | |||
| 576a315baa | |||
| 73965b64dd | |||
| 9673f10af7 | |||
| 8a9825f629 | |||
| fc830a427c | |||
| 26e8e91cb7 | |||
| 4cae37ef33 | |||
| a04a6bd5d2 | |||
| ac632a5e96 | |||
| 6701222697 | |||
| f42fe4236e | |||
| 3041e0a208 | |||
| 61bf3cd129 | |||
| fb34af114f | |||
| 56678abbb4 | |||
| 347805a8ae | |||
| 2438ac58e1 | |||
| 23b60389c0 | |||
| a58e45133a | |||
| 6fc26754bf | |||
| 8bfdc6e319 | |||
| 8d4e32c86c | |||
| f16226e48e | |||
| 05bef3d389 | |||
| d811ba5623 | |||
| abd6c03ba0 | |||
| 84a7c1c930 | |||
| 96b8ad8555 | |||
| e4f9301591 | |||
| 1eb60dc0c2 | |||
| cbcae665e4 | |||
| 4da82282de | |||
| 1548907ead | |||
| 854c6fa536 | |||
| 09ca65de0c | |||
| 52613d9feb | |||
| 4878cc2eab | |||
| 7e2f56f20b | |||
| 6b2914bff4 | |||
| 10b45ec226 | |||
| 23ec7e3ccb | |||
| 3aaa8d1ea3 | |||
| a46e8d3a5a | |||
| 6f9ceb1a89 | |||
| d5dbb39444 | |||
| 0f0af0c194 | |||
| 545345a020 | |||
| 0766cda29e | |||
| 6563b7dc67 | |||
| 5b8b950aaf | |||
| 8020fd9a7d | |||
| 8836dc577e | |||
| a2be86b134 | |||
| e9bc47680a | |||
| da62ab5a90 | |||
| dd84c8dd3a | |||
| 7e40a1f96c | |||
| 69f880af2b | |||
| 7c93c8cc31 | |||
| c2d06e10b8 | |||
| 9c8fafabb1 | |||
| c63708cb83 | |||
| 8f9b84277b | |||
| 8363d319c2 | |||
| a353d41738 | |||
| 0cfd656d86 | |||
| 5001fc94fe | |||
| a014596d51 | |||
| 64b4cfd1b2 | |||
| dcc7d5e218 | |||
| ca8c5691d3 | |||
| b257d8c66d | |||
| 601598dfd2 | |||
| aef9c344ab | |||
| f6f1227dd8 | |||
| 6d750c91f8 | |||
| 533d4fa64c | |||
| 0be6ed0631 | |||
| 346dfaee2e | |||
| e2796d22e2 | |||
| b96c42c153 | |||
| 83b5a8608a | |||
| 1abe1c7166 | |||
| c35bd82ebe | |||
| b6ed49ad82 | |||
| e70a2d74ba | |||
| d3f00acd5c | |||
| 00b0823eb3 | |||
| ab488ed0a3 | |||
| 12046b9c5c | |||
| a457598109 | |||
| 1f2f6ee82a | |||
| 8d41b19767 | |||
| cb59a1d176 | |||
| 2961477ecf | |||
| 5db30c05f7 | |||
| 90aa095621 | |||
| c7d5ff94c0 | |||
| 136832b22a | |||
| 79c723ec06 | |||
| 43883a3c06 | |||
| 0eba072826 | |||
| 0604675be6 | |||
| 75147eb5a1 | |||
| 5dd1fab7e9 | |||
| 31fa90aef1 | |||
| 4f0df51abd | |||
| 584fec2dc8 | |||
| 5744d03ad9 | |||
| 4aa521c6b9 | |||
| 545ceac0b8 | |||
| bd699b5700 | |||
| cb7684b015 | |||
| 567c04d74e | |||
| ac3451a209 | |||
| 8f4403accd | |||
| 8b19cf0956 | |||
| 5db5c8377b | |||
| 59ca46908e | |||
| efd56e513d | |||
| 6857e2b9c8 | |||
| 72438fea7e | |||
| 7a4aa5099d | |||
| 13efb0cc45 | |||
| 0c65d378cd | |||
| e73b8e6538 | |||
| 5bbc248a4b | |||
| b37c724aa0 | |||
| b89b0fe1ce | |||
| 577592479b | |||
| 20031eb843 | |||
| 35939d89b8 | |||
| 2ae03cb6b0 | |||
| 47be269ddf | |||
| ee30f5ab95 | |||
| e473988750 | |||
| 2a768568a9 | |||
| 1b45580390 | |||
| 37f8cf9881 | |||
| 6c84c873cf | |||
| a2a3d85cf5 | |||
| 59db7344a0 | |||
| 8f2d027a83 | |||
| 0a37596471 | |||
| 2f0208a905 | |||
| af8ecffb6d | |||
| 08d38e651b | |||
| 0db832473a | |||
| e7f682f02d | |||
| e002035518 | |||
| c16f2e8c72 | |||
| d588726838 | |||
| b5c8f4fa28 | |||
| dab1a8d754 | |||
| 27f5b9d43a | |||
| 4bf5b3a87d | |||
| adaae41826 | |||
| aaa754f66f | |||
| a294f123d2 | |||
| 617e599e74 | |||
| 426e2a881a | |||
| 96c9792dce | |||
| 0864a8a1ce | |||
| 5963cdc854 | |||
| 04c7616661 | |||
| ca68eb58d3 | |||
| b8b6f29c48 | |||
| 1d9aa3b432 | |||
| b12bb33242 | |||
| 1f7d0a263f | |||
| 34e213cc78 | |||
| 6651034413 | |||
| 800a86c500 | |||
| 1806a3db4b | |||
| 6a1f3f810e | |||
| cf923e1e9a | |||
| d122b59c6d | |||
| 2bd4d802a3 | |||
| 012ed787e8 | |||
| 6ada73b6cb | |||
| 719865fe83 | |||
| c34c5ef7af | |||
| 9c85ae31e5 | |||
| 987602551e | |||
| 9dcbc7d736 | |||
| 92fbe6ced5 | |||
| d79d7004ec | |||
| 4d1a75939e | |||
| 1c9af07a7d | |||
| 8f16fa4c1a | |||
| fe2ae18f20 | |||
| aa73bdd066 | |||
| a48bc4a0d8 | |||
| 3461f47cfd | |||
| 38d99b2a47 | |||
| 25a51549ac | |||
| bca136a55e | |||
| 1953e7c0cb | |||
| 6754a7da93 | |||
| 8fb3b1691f | |||
| 91bc384508 | |||
| 666c3b34af | |||
| 38817c82f7 | |||
| 10a6179b7c | |||
| 411647585d | |||
| f5c15546e2 | |||
| 109a968941 | |||
| b32cf0a9a0 | |||
| bbd68becfc | |||
| c4400466dd | |||
| da4ed7dd22 | |||
| c0fc972ce5 | |||
| cdcfcb7987 | |||
| 48528e611f | |||
| 0e982e8361 | |||
| cc9715ca7b | |||
| 6cb6aa4c67 | |||
| 040e32a50a | |||
| 40092c7085 | |||
| 1bd138e78b | |||
| fae10faf7e | |||
| 51d0b71e1d | |||
| 9cf29cc59c | |||
| 3e680fd31d | |||
| 2e5d581fb5 | |||
| 8a235e129d | |||
| d899ddf663 | |||
| cd6d05320e | |||
| 4ef0ce9d19 | |||
| c78fee4a6b | |||
| abc46bf172 | |||
| 3680d0606a | |||
| 45e4b2f696 | |||
| f11e3f255e | |||
| 3915f37e55 | |||
| 0a4e1e8c50 | |||
| 0d390bb9cc | |||
| 15f3e89e61 | |||
| 869a0e8472 | |||
| 4f0c64b786 | |||
| 10e10b7157 | |||
| b192b43bd3 | |||
| e129a06d9b | |||
| c678a9bfce | |||
| c8cdfc980b | |||
| 146512b2ab | |||
| 30c03677fe | |||
| 2baf63dccd | |||
| 363bf80bf4 | |||
| 50146be4a6 | |||
| 09673a145c | |||
| 16561610b6 | |||
| a71afc554e | |||
| 7512868219 | |||
| 9008ba3b99 | |||
| 83d589fff5 | |||
| 5cb07aa68a | |||
| dd5463881d | |||
| 77084b1ff2 | |||
| e965652e1f | |||
| 1c3d24527f | |||
| 9bfb39f1ae | |||
| 0c46a70f53 | |||
| dedeac394b | |||
| 57e813e3d4 | |||
| 02847dbb93 | |||
| 470a05018e | |||
| 649d8060df | |||
| 5a04b360db | |||
| 76d2f28128 | |||
| 37561c086f | |||
| 262b7c9489 | |||
| 73cfe74c1c | |||
| 68f6994b94 | |||
| 57ecdbbda5 | |||
| 3ff07e4c76 | |||
| 8d4bec79b3 | |||
| 546f4c52cb | |||
| b223b4463a | |||
| 258f8fe2f7 | |||
| c697498cb4 | |||
| 4b1e8b555a | |||
| ec4217fc4f | |||
| 49fa3693ad | |||
| 845a7a287b | |||
| b5928722eb | |||
| 9f0d0acb53 | |||
| b4af296384 | |||
| 3466157550 | |||
| 754a33cbc7 | |||
| 8538050fb5 | |||
| 165e2955e0 | |||
| bcce80a52d | |||
| 811ca4ff30 | |||
| 80249609af | |||
| 63039e3537 | |||
| def9d5a016 | |||
| 41dd64f7c8 | |||
| a0bada3e76 | |||
| a405f0a5ad | |||
| 7bd484528a | |||
| 2c6b2b9784 | |||
| 41ca9b704f | |||
| 06a1360714 | |||
| 6583f2e6cc | |||
| eae768615c | |||
| 10b4562982 | |||
| ed5a129b6a | |||
| dc661dabcc | |||
| c5e9cb9b18 | |||
| 576a389028 | |||
| 709c1064a5 | |||
| 8588b89421 | |||
| f7886dcfea | |||
| 60fa6cb4f1 | |||
| 98832d7ade | |||
| dbfed0dc2a | |||
| a674c321de | |||
| d91e9180ea | |||
| fbe7e9f820 | |||
| c7fb1045c2 | |||
| 4ba3e14961 | |||
| 7746835832 | |||
| 264d6186fa | |||
| 3f5c449817 | |||
| d03907b43c | |||
| 56bd72c3bc | |||
| f42ca88e01 | |||
| 87095772fe | |||
| bfca706b31 | |||
| bba0e3ebdb | |||
| 4958061a19 | |||
| 8aec92fe43 | |||
| f84293f270 | |||
| aa830c9a37 | |||
| 58357f2f8b | |||
| 19f40c8fad | |||
| e1aadf9781 | |||
| 3198cd13f4 | |||
| 67b85478aa | |||
| 08cbeceea4 | |||
| 60d7e89452 | |||
| 3937cb105c | |||
| 16c776bf82 | |||
| 467557fb59 | |||
| 140020838b | |||
| 18f329b529 | |||
| 271ea09380 | |||
| d6dc9ab365 | |||
| 376cd07b5c |
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Сохраняем текущие изменения
|
||||||
|
echo "Сохранение текущих изменений..."
|
||||||
|
git stash -q --keep-index
|
||||||
|
|
||||||
|
# Запускаем форматирование кода
|
||||||
|
echo "Запуск Ruff format..."
|
||||||
|
ruff format . --config pyproject.toml --exclude main.py,handlers/payments
|
||||||
|
|
||||||
|
# Запускаем проверку и исправление кода
|
||||||
|
echo "Запуск Ruff check с автоисправлением..."
|
||||||
|
ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
|
||||||
|
|
||||||
|
# Добавляем изменения, внесенные форматированием
|
||||||
|
git add -u
|
||||||
|
|
||||||
|
# Восстанавливаем сохраненные изменения
|
||||||
|
echo "Восстановление сохраненных изменений..."
|
||||||
|
git stash pop -q
|
||||||
|
|
||||||
|
# Выход с кодом 0 (успешно)
|
||||||
|
exit 0
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 867 KiB |
+61
-8
@@ -1,8 +1,61 @@
|
|||||||
/venv
|
# Byte-compiled / optimized / DLL files
|
||||||
/__pycache__
|
__pycache__/
|
||||||
/vpn_users.db
|
*.py[cod]
|
||||||
/config.py
|
*$py.class
|
||||||
/database.db
|
|
||||||
/bot_old.py
|
# Distribution / packaging
|
||||||
/bot_old_2.py
|
dist/
|
||||||
/database.db
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
alembic/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Logs and databases
|
||||||
|
*.log
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Sensitive configuration files
|
||||||
|
config.py
|
||||||
|
config.ini
|
||||||
|
alembic.ini
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Specific project files
|
||||||
|
vpn_users.db
|
||||||
|
database.db
|
||||||
|
bot_old.py
|
||||||
|
bot_old_2.py
|
||||||
|
backup_pg.sh
|
||||||
|
docker-compose.yml
|
||||||
|
config copy.py
|
||||||
|
handlers/texts.py
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
nginx.conf
|
||||||
|
scripts
|
||||||
|
Dockerfile
|
||||||
|
.csv
|
||||||
|
/logs
|
||||||
|
setup.py
|
||||||
|
.ruff_cache
|
||||||
|
.github/workflows/
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
Creative Commons Attribution-NonCommercial 4.0 International Public
|
||||||
|
License © Vladless, 2024.
|
||||||
|
|
||||||
|
By exercising the Licensed Rights (defined below), You accept and agree
|
||||||
|
to be bound by the terms and conditions of this Creative Commons
|
||||||
|
Attribution-NonCommercial 4.0 International Public License ("Public
|
||||||
|
License"). To the extent this Public License may be interpreted as a
|
||||||
|
contract, You are granted the Licensed Rights in consideration of Your
|
||||||
|
acceptance of these terms and conditions, and the Licensor grants You
|
||||||
|
such rights in consideration of benefits the Licensor receives from
|
||||||
|
making the Licensed Material available under these terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
|
||||||
|
Section 1 -- Definitions.
|
||||||
|
|
||||||
|
a. Adapted Material means material subject to Copyright and Similar
|
||||||
|
Rights that is derived from or based upon the Licensed Material
|
||||||
|
and in which the Licensed Material is translated, altered,
|
||||||
|
arranged, transformed, or otherwise modified in a manner requiring
|
||||||
|
permission under the Copyright and Similar Rights held by the
|
||||||
|
Licensor. For purposes of this Public License, where the Licensed
|
||||||
|
Material is a musical work, performance, or sound recording,
|
||||||
|
Adapted Material is always produced where the Licensed Material is
|
||||||
|
synched in timed relation with a moving image.
|
||||||
|
|
||||||
|
b. Adapter's License means the license You apply to Your Copyright
|
||||||
|
and Similar Rights in Your contributions to Adapted Material in
|
||||||
|
accordance with the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
c. Copyright and Similar Rights means copyright and/or similar rights
|
||||||
|
closely related to copyright including, without limitation,
|
||||||
|
performance, broadcast, sound recording, and Sui Generis Database
|
||||||
|
Rights, without regard to how the rights are labeled or
|
||||||
|
categorized. For purposes of this Public License, the rights
|
||||||
|
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||||
|
Rights.
|
||||||
|
d. Effective Technological Measures means those measures that, in the
|
||||||
|
absence of proper authority, may not be circumvented under laws
|
||||||
|
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||||
|
Treaty adopted on December 20, 1996, and/or similar international
|
||||||
|
agreements.
|
||||||
|
|
||||||
|
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||||
|
any other exception or limitation to Copyright and Similar Rights
|
||||||
|
that applies to Your use of the Licensed Material.
|
||||||
|
|
||||||
|
f. Licensed Material means the artistic or literary work, database,
|
||||||
|
or other material to which the Licensor applied this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
g. Licensed Rights means the rights granted to You subject to the
|
||||||
|
terms and conditions of this Public License, which are limited to
|
||||||
|
all Copyright and Similar Rights that apply to Your use of the
|
||||||
|
Licensed Material and that the Licensor has authority to license.
|
||||||
|
|
||||||
|
h. Licensor means the individual(s) or entity(ies) granting rights
|
||||||
|
under this Public License.
|
||||||
|
|
||||||
|
i. NonCommercial means not primarily intended for or directed towards
|
||||||
|
commercial advantage or monetary compensation. For purposes of
|
||||||
|
this Public License, the exchange of the Licensed Material for
|
||||||
|
other material subject to Copyright and Similar Rights by digital
|
||||||
|
file-sharing or similar means is NonCommercial provided there is
|
||||||
|
no payment of monetary compensation in connection with the
|
||||||
|
exchange.
|
||||||
|
|
||||||
|
j. Share means to provide material to the public by any means or
|
||||||
|
process that requires permission under the Licensed Rights, such
|
||||||
|
as reproduction, public display, public performance, distribution,
|
||||||
|
dissemination, communication, or importation, and to make material
|
||||||
|
available to the public including in ways that members of the
|
||||||
|
public may access the material from a place and at a time
|
||||||
|
individually chosen by them.
|
||||||
|
|
||||||
|
k. Sui Generis Database Rights means rights other than copyright
|
||||||
|
resulting from Directive 96/9/EC of the European Parliament and of
|
||||||
|
the Council of 11 March 1996 on the legal protection of databases,
|
||||||
|
as amended and/or succeeded, as well as other essentially
|
||||||
|
equivalent rights anywhere in the world.
|
||||||
|
|
||||||
|
l. You means the individual or entity exercising the Licensed Rights
|
||||||
|
under this Public License. Your has a corresponding meaning.
|
||||||
|
|
||||||
|
|
||||||
|
Section 2 -- Scope.
|
||||||
|
|
||||||
|
a. License grant.
|
||||||
|
|
||||||
|
1. Subject to the terms and conditions of this Public License,
|
||||||
|
the Licensor hereby grants You a worldwide, royalty-free,
|
||||||
|
non-sublicensable, non-exclusive, irrevocable license to
|
||||||
|
exercise the Licensed Rights in the Licensed Material to:
|
||||||
|
|
||||||
|
a. reproduce and Share the Licensed Material, in whole or
|
||||||
|
in part, for NonCommercial purposes only; and
|
||||||
|
|
||||||
|
b. produce, reproduce, and Share Adapted Material for
|
||||||
|
NonCommercial purposes only.
|
||||||
|
|
||||||
|
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||||
|
Exceptions and Limitations apply to Your use, this Public
|
||||||
|
License does not apply, and You do not need to comply with
|
||||||
|
its terms and conditions.
|
||||||
|
|
||||||
|
3. Term. The term of this Public License is specified in Section
|
||||||
|
6(a).
|
||||||
|
|
||||||
|
4. Media and formats; technical modifications allowed. The
|
||||||
|
Licensor authorizes You to exercise the Licensed Rights in
|
||||||
|
all media and formats whether now known or hereafter created,
|
||||||
|
and to make technical modifications necessary to do so. The
|
||||||
|
Licensor waives and/or agrees not to assert any right or
|
||||||
|
authority to forbid You from making technical modifications
|
||||||
|
necessary to exercise the Licensed Rights, including
|
||||||
|
technical modifications necessary to circumvent Effective
|
||||||
|
Technological Measures. For purposes of this Public License,
|
||||||
|
simply making modifications authorized by this Section 2(a)
|
||||||
|
(4) never produces Adapted Material.
|
||||||
|
|
||||||
|
5. Downstream recipients.
|
||||||
|
|
||||||
|
a. Offer from the Licensor -- Licensed Material. Every
|
||||||
|
recipient of the Licensed Material automatically
|
||||||
|
receives an offer from the Licensor to exercise the
|
||||||
|
Licensed Rights under the terms and conditions of this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
b. No downstream restrictions. You may not offer or impose
|
||||||
|
any additional or different terms or conditions on, or
|
||||||
|
apply any Effective Technological Measures to, the
|
||||||
|
Licensed Material if doing so restricts exercise of the
|
||||||
|
Licensed Rights by any recipient of the Licensed
|
||||||
|
Material.
|
||||||
|
|
||||||
|
6. No endorsement. Nothing in this Public License constitutes or
|
||||||
|
may be construed as permission to assert or imply that You
|
||||||
|
are, or that Your use of the Licensed Material is, connected
|
||||||
|
with, or sponsored, endorsed, or granted official status by,
|
||||||
|
the Licensor or others designated to receive attribution as
|
||||||
|
provided in Section 3(a)(1)(A)(i).
|
||||||
|
|
||||||
|
b. Other rights.
|
||||||
|
|
||||||
|
1. Moral rights, such as the right of integrity, are not
|
||||||
|
licensed under this Public License, nor are publicity,
|
||||||
|
privacy, and/or other similar personality rights; however, to
|
||||||
|
the extent possible, the Licensor waives and/or agrees not to
|
||||||
|
assert any such rights held by the Licensor to the limited
|
||||||
|
extent necessary to allow You to exercise the Licensed
|
||||||
|
Rights, but not otherwise.
|
||||||
|
|
||||||
|
2. Patent and trademark rights are not licensed under this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
3. To the extent possible, the Licensor waives any right to
|
||||||
|
collect royalties from You for the exercise of the Licensed
|
||||||
|
Rights, whether directly or through a collecting society
|
||||||
|
under any voluntary or waivable statutory or compulsory
|
||||||
|
licensing scheme. In all other cases the Licensor expressly
|
||||||
|
reserves any right to collect such royalties, including when
|
||||||
|
the Licensed Material is used other than for NonCommercial
|
||||||
|
purposes.
|
||||||
|
|
||||||
|
|
||||||
|
Section 3 -- License Conditions.
|
||||||
|
|
||||||
|
Your exercise of the Licensed Rights is expressly made subject to the
|
||||||
|
following conditions.
|
||||||
|
|
||||||
|
a. Attribution.
|
||||||
|
|
||||||
|
1. If You Share the Licensed Material (including in modified
|
||||||
|
form), You must:
|
||||||
|
|
||||||
|
a. retain the following if it is supplied by the Licensor
|
||||||
|
with the Licensed Material:
|
||||||
|
|
||||||
|
i. identification of the creator(s) of the Licensed
|
||||||
|
Material and any others designated to receive
|
||||||
|
attribution, in any reasonable manner requested by
|
||||||
|
the Licensor (including by pseudonym if
|
||||||
|
designated);
|
||||||
|
|
||||||
|
ii. a copyright notice;
|
||||||
|
|
||||||
|
iii. a notice that refers to this Public License;
|
||||||
|
|
||||||
|
iv. a notice that refers to the disclaimer of
|
||||||
|
warranties;
|
||||||
|
|
||||||
|
v. a URI or hyperlink to the Licensed Material to the
|
||||||
|
extent reasonably practicable;
|
||||||
|
|
||||||
|
b. indicate if You modified the Licensed Material and
|
||||||
|
retain an indication of any previous modifications; and
|
||||||
|
|
||||||
|
c. indicate the Licensed Material is licensed under this
|
||||||
|
Public License, and include the text of, or the URI or
|
||||||
|
hyperlink to, this Public License.
|
||||||
|
|
||||||
|
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||||
|
reasonable manner based on the medium, means, and context in
|
||||||
|
which You Share the Licensed Material. For example, it may be
|
||||||
|
reasonable to satisfy the conditions by providing a URI or
|
||||||
|
hyperlink to a resource that includes the required
|
||||||
|
information.
|
||||||
|
|
||||||
|
3. If requested by the Licensor, You must remove any of the
|
||||||
|
information required by Section 3(a)(1)(A) to the extent
|
||||||
|
reasonably practicable.
|
||||||
|
|
||||||
|
4. If You Share Adapted Material You produce, the Adapter's
|
||||||
|
License You apply must not prevent recipients of the Adapted
|
||||||
|
Material from complying with this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 4 -- Sui Generis Database Rights.
|
||||||
|
|
||||||
|
Where the Licensed Rights include Sui Generis Database Rights that
|
||||||
|
apply to Your use of the Licensed Material:
|
||||||
|
|
||||||
|
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||||
|
to extract, reuse, reproduce, and Share all or a substantial
|
||||||
|
portion of the contents of the database for NonCommercial purposes
|
||||||
|
only;
|
||||||
|
|
||||||
|
b. if You include all or a substantial portion of the database
|
||||||
|
contents in a database in which You have Sui Generis Database
|
||||||
|
Rights, then the database in which You have Sui Generis Database
|
||||||
|
Rights (but not its individual contents) is Adapted Material; and
|
||||||
|
|
||||||
|
c. You must comply with the conditions in Section 3(a) if You Share
|
||||||
|
all or a substantial portion of the contents of the database.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 4 supplements and does not
|
||||||
|
replace Your obligations under this Public License where the Licensed
|
||||||
|
Rights include other Copyright and Similar Rights.
|
||||||
|
|
||||||
|
|
||||||
|
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||||
|
|
||||||
|
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||||
|
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||||
|
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||||
|
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||||
|
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||||
|
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||||
|
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||||
|
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||||
|
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||||
|
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||||
|
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||||
|
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||||
|
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||||
|
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||||
|
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||||
|
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||||
|
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
c. The disclaimer of warranties and limitation of liability provided
|
||||||
|
above shall be interpreted in a manner that, to the extent
|
||||||
|
possible, most closely approximates an absolute disclaimer and
|
||||||
|
waiver of all liability.
|
||||||
|
|
||||||
|
|
||||||
|
Section 6 -- Term and Termination.
|
||||||
|
|
||||||
|
a. This Public License applies for the term of the Copyright and
|
||||||
|
Similar Rights licensed here. However, if You fail to comply with
|
||||||
|
this Public License, then Your rights under this Public License
|
||||||
|
terminate automatically.
|
||||||
|
|
||||||
|
b. Where Your right to use the Licensed Material has terminated under
|
||||||
|
Section 6(a), it reinstates:
|
||||||
|
|
||||||
|
1. automatically as of the date the violation is cured, provided
|
||||||
|
it is cured within 30 days of Your discovery of the
|
||||||
|
violation; or
|
||||||
|
|
||||||
|
2. upon express reinstatement by the Licensor.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||||
|
right the Licensor may have to seek remedies for Your violations
|
||||||
|
of this Public License.
|
||||||
|
|
||||||
|
c. For the avoidance of doubt, the Licensor may also offer the
|
||||||
|
Licensed Material under separate terms or conditions or stop
|
||||||
|
distributing the Licensed Material at any time; however, doing so
|
||||||
|
will not terminate this Public License.
|
||||||
|
|
||||||
|
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 7 -- Other Terms and Conditions.
|
||||||
|
|
||||||
|
a. The Licensor shall not be bound by any additional or different
|
||||||
|
terms or conditions communicated by You unless expressly agreed.
|
||||||
|
|
||||||
|
b. Any arrangements, understandings, or agreements regarding the
|
||||||
|
Licensed Material not stated herein are separate from and
|
||||||
|
independent of the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 8 -- Interpretation.
|
||||||
|
|
||||||
|
a. For the avoidance of doubt, this Public License does not, and
|
||||||
|
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||||
|
conditions on any use of the Licensed Material that could lawfully
|
||||||
|
be made without permission under this Public License.
|
||||||
|
|
||||||
|
b. To the extent possible, if any provision of this Public License is
|
||||||
|
deemed unenforceable, it shall be automatically reformed to the
|
||||||
|
minimum extent necessary to make it enforceable. If the provision
|
||||||
|
cannot be reformed, it shall be severed from this Public License
|
||||||
|
without affecting the enforceability of the remaining terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
c. No term or condition of this Public License will be waived and no
|
||||||
|
failure to comply consented to unless expressly agreed to by the
|
||||||
|
Licensor.
|
||||||
|
|
||||||
|
d. Nothing in this Public License constitutes or may be interpreted
|
||||||
|
as a limitation upon, or waiver of, any privileges and immunities
|
||||||
|
that apply to the Licensor or You, including from the legal
|
||||||
|
processes of any jurisdiction or authority.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
formatting:
|
||||||
|
@echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
|
||||||
|
|
||||||
|
@echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
|
||||||
@@ -1,91 +1,87 @@
|
|||||||
# 🚀 SoloBot
|
# 🚀 SoloBot
|
||||||
|
|
||||||
**[SoloBot]**!
|
## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
|
||||||
[Бот для API x-ray]
|
|
||||||
|
|
||||||
## 📋 Оглавление
|
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
|
||||||
1. [Описание](#описание)
|
|
||||||
2. [Требования](#требования)
|
|
||||||
3. [Установка](#установка)
|
|
||||||
4. [Конфигурация](#конфигурация)
|
|
||||||
5. [Запуск](#запуск)
|
|
||||||
6. [Автор](#автор)
|
|
||||||
|
|
||||||
---
|
# Описание
|
||||||
|
|
||||||
## 📖 Описание
|
## Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
|
||||||
|
|
||||||
[Бот предназначен для автоматизирования выдачи ключей, хранении их в базе данных и управления подключениями]
|
## ⚙️ Основные возможности SoloBot
|
||||||
|
|
||||||
|
| 📌 Раздел | 💡 Возможности |
|
||||||
|
|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| **Мультипанель** | • Работа с панелями [3x-ui](https://github.com/MHSanaei/3x-ui) <br> • Работа с панелями [Remnawave](https://github.com/remnawave/panel) <br> • Работа в режиме совместимости (различные панели одновременно) |
|
||||||
|
| **Управление подписками** | • Выдача подписок на 1 / 3 / 6 / 12 месяцев или заданные сроки <br> • Пробный период на заданный период <br> • Продление ключей по тарифному плану <br> • Поддержка различных клиентов (Happ, Hiddify, v2RayTun) <br> • Кастомизируемые заголовки профилей |
|
||||||
|
| **Полный контроль над клиентом** | • Просмотр ключа, сервера и оставшегося времени через админку <br> • Продление и удаление ключей, начисление дней, отключение клиента <br> • Смена локации между серверами <br> • Поддержка нескольких устройств |
|
||||||
|
| **Реферальная программа** | • Уникальные ссылки для приглашений <br> • Инлайн-режим и обычные сообщения <br> • Награда: процент или фиксированная сумма за пополнение |
|
||||||
|
| **UTM-аналитика** | • Отслеживание рекламных переходов <br> • Привязка по рефералам, купонам, пробникам <br> • Анализ конверсий: регистрации, покупки, триалы <br> • Удалённый просмотр и контроль через админку |
|
||||||
|
| **Поддержка платёжных систем** | • YooKassa (ИП / Самозанятые) <br> • YooMoney (Физ. лица) [@TrackLine](https://github.com/TrackLine) <br> • Robokassa (ИП) <br> • Cryptobot [@izzzzzi](https://github.com/izzzzzi) <br> • Telegram Stars |
|
||||||
|
| **Безопасность и стабильность** | • Периодические бэкапы <br> • Смена домена в случае переезда <br> • Проверка доступности серверов <br> • Уведомление о недоступности сервера и его аптайм <br> |
|
||||||
|
| **Уведомления** | • Напоминания об истекающих подписках (24ч / 6ч / момент) <br> • Напоминания о неиспользованном трафике |
|
||||||
|
| **Серверная часть** | • Мультисерверность (добавление серверов в неограниченном количестве) <br> • Выдача в разных режимах (по одной локации или в формате подписки) <br> • Автопроверка доступности <br> • Балансировка нагрузки при выдаче ключей <br> • Синхронизация клиентов между серверами <br> • Ограничение максимального количества ключей на сервер <br> • Возможность включения/отключения отдельных серверов |
|
||||||
|
| **Админ-панель** | • Поиск по TG ID / username / ключу / email <br> • Управление балансом, подписками, заморозками <br> • Перезагрузка, бан-лист <br> • Создание купонов, UTM, статистика |
|
||||||
|
| **Кастомизация** | • Все функции бота кастомизируемы, вплоть до логики работы <br> |
|
||||||
|
|
||||||
|
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Возможности_бота-перейти-ffa726?style=for-the-badge" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## 🛠 Обновления
|
||||||
|
|
||||||
|
### SoloBot регулярно получает новые функции и улучшения.
|
||||||
|
|
||||||
|
#### [➡ Все обновления и версии](https://github.com/Vladless/Solo_bot/releases)
|
||||||
|
|
||||||
|
# Наш сайт и полная версия
|
||||||
|
|
||||||
|
### Переходи на наш сайт!
|
||||||
|
<h3>
|
||||||
|
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ffa726?style=for-the-badge" alt="Сайт SoloNetBot">
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
|
||||||
---
|
#### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
|
||||||
|
|
||||||
## 💻 Требования
|

|
||||||
|
|
||||||
Для работы проекта необходимы следующие компоненты:
|
<h3>
|
||||||
|
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Попробовать_SoloBot_в_Telegram-жми-ffa726?style=for-the-badge&logo=telegram" alt="Попробовать SoloBot в Telegram">
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
- **Python** версии 3.8 или выше
|
## Отзывы пользователей:
|
||||||
- **Git** для клонирования репозитория
|
|
||||||
- **Virtualenv** для создания виртуального окружения (рекомендуется)
|
|
||||||
- **postgresql** для работы базы данных
|
|
||||||
|
|
||||||
---
|
#### SoloBot уже помог сотням пользователей в нашем сообществе:
|
||||||
|
|
||||||
## ⚙️ Установка
|

|
||||||
|
|
||||||
### 1️⃣ Шаг 1: Клонирование репозитория
|
<h3>
|
||||||
|
<a href="https://pocomacho.ru/solonetbot/reviews/" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Читать_отзывы-на_сайте-ffa726?style=for-the-badge&logo=discourse" alt="Читать отзывы">
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
Клонируйте репозиторий и перейдите в его директорию:
|
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
|
||||||
|
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
|
||||||
|
|
||||||
```bash
|
## 🚨 Права на использование
|
||||||
git clone https://github.com/Vladless/Solo_bot.git
|
|
||||||
cd solo_bot
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2️⃣ Шаг 2: Создание и активация виртуального окружения
|
| ❗ | **Этот проект распространяется по лицензии [CC BY-NC 4.0](LICENSE)** |
|
||||||
|
|----------------------------------|----------------------------------------------------------------------|
|
||||||
```
|
| ⛔ **Перепродажа кода запрещена** | Нельзя продавать или перепродавать код без разрешения автора. |
|
||||||
python3 -m venv venv
|
| ✅ **Для личного использования** | Код можно использовать и модифицировать для личного использования. |
|
||||||
source venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3️⃣ Шаг 3: Установка зависимостей
|
|
||||||
|
|
||||||
```
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🛠️ Конфигурация
|
|
||||||
|
|
||||||
Для правильной работы вам нужно установить и запустить postgresql, создать пользователя для работы с базой данных и выдать ему права
|
|
||||||
|
|
||||||
Также создайте файл config.py в корневой папке проекта с вашими данными:
|
|
||||||
|
|
||||||
```
|
|
||||||
API_TOKEN = токен вашего бота телеграм
|
|
||||||
API_URL = домен вашего x-ray сервера с указанием порта и дополнительным путем панели вида "https://{domen}:{port}/{путь}" без слэша в конце
|
|
||||||
ADMIN_USERNAME = логин от вашей панели x-ray
|
|
||||||
ADMIN_PASSWORD = пароль от вашей панели x-ray
|
|
||||||
ADD_CLIENT_URL = f"{API_URL}/panel/api/inbounds/addClient"
|
|
||||||
GET_INBOUNDS_URL = f"{API_URL}/panel/api/inbounds/list/"
|
|
||||||
AUTH_URL = f"{API_URL}/login/"
|
|
||||||
DOMEN = "{ваш домен без https://}:443"
|
|
||||||
DATABASE_URL = путь к вашей базе данных, имеет вид "postgresql://{user}:{password}@{Ip-adress}:{port}/{имя базы данных}"
|
|
||||||
ADMIN_ID = ID телеграм профиля администратора
|
|
||||||
CHANNEL_URL = ссылка на ваш телеграм канал
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🚀 Запуск
|
|
||||||
|
|
||||||
введите команду из виртуального окружения
|
|
||||||
|
|
||||||
```
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
🔗 SoloBot в Telegram
|
|
||||||
|
|
||||||
Попробуйте SoloBot прямо сейчас в Telegram [по этой ссылке](https://t.me/SoloNetVPN_bot).
|
|
||||||
|
|
||||||
|
|
||||||
|
## Участники проекта
|
||||||
|
|
||||||
|
Благодарим всех, кто помогает развивать SoloBot! 💖
|
||||||
|
|
||||||
|
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from config import API_URL, GET_INBOUNDS_URL
|
|
||||||
|
|
||||||
session = None
|
|
||||||
|
|
||||||
def login_with_credentials(username, password):
|
|
||||||
global session
|
|
||||||
session = requests.Session()
|
|
||||||
auth_url = f"{API_URL}/login/"
|
|
||||||
data = {
|
|
||||||
"username": username,
|
|
||||||
"password": password
|
|
||||||
}
|
|
||||||
response = requests.post(auth_url, json=data)
|
|
||||||
if response.status_code == 200:
|
|
||||||
session.cookies.update(response.cookies)
|
|
||||||
return session
|
|
||||||
else:
|
|
||||||
raise Exception(f"Ошибка авторизации: {response.status_code}, {response.text}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_clients(session):
|
|
||||||
response = session.get(GET_INBOUNDS_URL)
|
|
||||||
if response.status_code == 200:
|
|
||||||
return response.json() # Возвращает данные по инбаундам и клиентам
|
|
||||||
else:
|
|
||||||
raise Exception(f"Ошибка при получении клиентов: {response.status_code}, {response.text}")
|
|
||||||
|
|
||||||
def link(session, client_id: str, email: str):
|
|
||||||
"""
|
|
||||||
Получение ссылки для подключения по ID клиента.
|
|
||||||
:param session: requests.Session - авторизованная сессия
|
|
||||||
:param client_id: str - идентификатор клиента
|
|
||||||
:return: str - ссылка для подключения
|
|
||||||
"""
|
|
||||||
response = get_clients(session)
|
|
||||||
|
|
||||||
if 'obj' not in response or len(response['obj']) == 0:
|
|
||||||
raise Exception("Не удалось получить данные клиентов.")
|
|
||||||
|
|
||||||
inbounds = response['obj'][0]
|
|
||||||
settings = json.loads(inbounds['settings'])
|
|
||||||
|
|
||||||
# Найти клиентский ID в настройках
|
|
||||||
stream_settings = json.loads(inbounds['streamSettings'])
|
|
||||||
tcp = stream_settings.get('network', 'tcp')
|
|
||||||
reality = stream_settings.get('security', 'reality')
|
|
||||||
flow = stream_settings.get('flow', 'xtls-rprx-vision')
|
|
||||||
|
|
||||||
# Создание ссылки для подключения VLESS
|
|
||||||
val = f"vless://{client_id}@solonet.pocomacho.ru:443?type={tcp}&security={reality}&pbk=Ik3cfHdM5RXYCuzTxhaFfo58Fzwjvw11AtKsz5-izxA&fp=chrome&sni=discord.com&sid=6fc9&spx=%2F&flow={flow}#SoloNet_DE-{email}"
|
|
||||||
return val
|
|
||||||
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
from aiogram.types import BufferedInputFile
|
||||||
|
from aiogram import Bot
|
||||||
|
|
||||||
|
from bot import bot
|
||||||
|
from config import (
|
||||||
|
ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT,
|
||||||
|
BACKUP_SEND_MODE, BACKUP_CHANNEL_ID, BACKUP_CHANNEL_THREAD_ID, BACKUP_OTHER_BOT_TOKEN, BACKUP_CAPTION
|
||||||
|
)
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_database() -> Exception | None:
|
||||||
|
"""
|
||||||
|
Создает резервную копию базы данных и отправляет ее администраторам.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
|
||||||
|
"""
|
||||||
|
backup_file_path, exception = _create_database_backup()
|
||||||
|
|
||||||
|
if exception:
|
||||||
|
logger.error(f"Ошибка при создании бэкапа базы данных: {exception}")
|
||||||
|
return exception
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _send_backup_to_admins(backup_file_path)
|
||||||
|
exception = _cleanup_old_backups()
|
||||||
|
|
||||||
|
if exception:
|
||||||
|
logger.error(f"Ошибка при удалении старых бэкапов базы данных: {exception}")
|
||||||
|
return exception
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке бэкапа базы данных: {e}")
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def _create_database_backup() -> tuple[str | None, Exception | None]:
|
||||||
|
"""
|
||||||
|
Создает резервную копию базы данных PostgreSQL.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[Optional[str], Optional[Exception]]: Путь к файлу бэкапа и исключение (если произошла ошибка)
|
||||||
|
"""
|
||||||
|
date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||||
|
|
||||||
|
backup_dir = Path(BACK_DIR)
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}.sql"
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"pg_dump",
|
||||||
|
"-U",
|
||||||
|
DB_USER,
|
||||||
|
"-h",
|
||||||
|
PG_HOST,
|
||||||
|
"-p",
|
||||||
|
PG_PORT,
|
||||||
|
"-F",
|
||||||
|
"c",
|
||||||
|
"-f",
|
||||||
|
str(filename),
|
||||||
|
DB_NAME,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
logger.info(f"Бэкап базы данных создан: {filename}")
|
||||||
|
return str(filename), None
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"Ошибка при выполнении pg_dump: {e.stderr}")
|
||||||
|
return None, e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
|
||||||
|
return None, e
|
||||||
|
finally:
|
||||||
|
if "PGPASSWORD" in os.environ:
|
||||||
|
del os.environ["PGPASSWORD"]
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_old_backups() -> Exception | None:
|
||||||
|
"""
|
||||||
|
Удаляет бэкапы старше 3 дней.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
backup_dir = Path(BACK_DIR)
|
||||||
|
if not backup_dir.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=3)
|
||||||
|
|
||||||
|
for backup_file in backup_dir.glob("*.sql"):
|
||||||
|
if backup_file.is_file():
|
||||||
|
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
||||||
|
if file_mtime < cutoff_date:
|
||||||
|
backup_file.unlink()
|
||||||
|
logger.info(f"Удален старый бэкап: {backup_file}")
|
||||||
|
|
||||||
|
logger.info("Очистка старых бэкапов завершена")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
async def create_backup_and_send_to_admins(client) -> None:
|
||||||
|
"""
|
||||||
|
Создает бэкап и отправляет администраторам через переданный клиент.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Клиент для работы с базой данных
|
||||||
|
"""
|
||||||
|
await client.login()
|
||||||
|
await client.database.export()
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_backup_to_admins(backup_file_path: str) -> None:
|
||||||
|
"""
|
||||||
|
Отправляет файл бэкапа всем администраторам через Telegram.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backup_file_path: Путь к файлу бэкапа
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: При ошибке отправки файла
|
||||||
|
"""
|
||||||
|
if not backup_file_path or not os.path.exists(backup_file_path):
|
||||||
|
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
|
||||||
|
|
||||||
|
async def send_default():
|
||||||
|
for admin_id in ADMIN_ID:
|
||||||
|
try:
|
||||||
|
await bot.send_document(
|
||||||
|
chat_id=admin_id, document=backup_input_file
|
||||||
|
)
|
||||||
|
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiofiles.open(backup_file_path, "rb") as backup_file:
|
||||||
|
backup_data = await backup_file.read()
|
||||||
|
filename = os.path.basename(backup_file_path)
|
||||||
|
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
|
||||||
|
|
||||||
|
if BACKUP_SEND_MODE == "default":
|
||||||
|
await send_default()
|
||||||
|
|
||||||
|
elif BACKUP_SEND_MODE == "channel":
|
||||||
|
channel_id = BACKUP_CHANNEL_ID.strip()
|
||||||
|
thread_id = BACKUP_CHANNEL_THREAD_ID.strip()
|
||||||
|
if not channel_id:
|
||||||
|
logger.error("BACKUP_CHANNEL_ID не задан для режима 'channel', fallback на default")
|
||||||
|
await send_default()
|
||||||
|
return
|
||||||
|
send_kwargs = dict(chat_id=channel_id, document=backup_input_file)
|
||||||
|
if thread_id:
|
||||||
|
send_kwargs["message_thread_id"] = int(thread_id)
|
||||||
|
if BACKUP_CAPTION:
|
||||||
|
send_kwargs["caption"] = BACKUP_CAPTION
|
||||||
|
try:
|
||||||
|
await bot.send_document(**send_kwargs)
|
||||||
|
logger.info(f"Бэкап базы данных отправлен в канал: {channel_id} (топик: {thread_id})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось отправить бэкап в канал {channel_id}: {e}, fallback на default")
|
||||||
|
await send_default()
|
||||||
|
|
||||||
|
elif BACKUP_SEND_MODE == "bot":
|
||||||
|
if not BACKUP_OTHER_BOT_TOKEN:
|
||||||
|
logger.error("BACKUP_OTHER_BOT_TOKEN не задан для режима 'bot', fallback на default")
|
||||||
|
await send_default()
|
||||||
|
return
|
||||||
|
other_bot = Bot(token=BACKUP_OTHER_BOT_TOKEN)
|
||||||
|
try:
|
||||||
|
for admin_id in ADMIN_ID:
|
||||||
|
try:
|
||||||
|
send_kwargs = dict(chat_id=admin_id, document=backup_input_file)
|
||||||
|
if BACKUP_CAPTION:
|
||||||
|
send_kwargs["caption"] = BACKUP_CAPTION
|
||||||
|
await other_bot.send_document(**send_kwargs)
|
||||||
|
logger.info(f"Бэкап базы данных отправлен админу через другого бота: {admin_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось отправить бэкап админу {admin_id} через другого бота: {e}")
|
||||||
|
await other_bot.session.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке через другого бота: {e}, fallback на default")
|
||||||
|
await send_default()
|
||||||
|
else:
|
||||||
|
logger.error(f"Неизвестный BACKUP_SEND_MODE: {BACKUP_SEND_MODE}, fallback на default")
|
||||||
|
await send_default()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке бэкапа: {e}")
|
||||||
|
raise
|
||||||
@@ -1,27 +1,206 @@
|
|||||||
from aiogram import Bot, Dispatcher, Router
|
import traceback
|
||||||
|
import os
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
|
from aiogram.filters import ExceptionTypeFilter
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
from aiogram.types import BufferedInputFile, ErrorEvent
|
||||||
|
from aiogram.utils.markdown import hbold
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from auth import login_with_credentials
|
from config import ADMIN_ID, API_TOKEN
|
||||||
from config import API_TOKEN
|
from filters.private import IsPrivateFilter
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
bot = Bot(token=API_TOKEN)
|
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||||
storage = MemoryStorage()
|
storage = MemoryStorage()
|
||||||
dp = Dispatcher(bot=bot, storage=storage)
|
dp = Dispatcher(bot=bot, storage=storage)
|
||||||
router = Router()
|
|
||||||
|
|
||||||
from handlers import balance, keys, notifications, pay, profile, start
|
|
||||||
from key_management import router as key_management_router
|
|
||||||
|
|
||||||
# Регистрация обработчиков
|
|
||||||
dp.include_router(start.router)
|
|
||||||
dp.include_router(profile.router)
|
|
||||||
dp.include_router(keys.router)
|
|
||||||
dp.include_router(balance.router)
|
|
||||||
dp.include_router(key_management_router)
|
|
||||||
dp.include_router(pay.router)
|
|
||||||
dp.include_router(notifications.router)
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_commit_number() -> str:
|
||||||
|
repo_url = "https://github.com/Vladless/Solo_bot"
|
||||||
|
cwd = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
async def on_startup(dispatcher: Dispatcher):
|
if not os.path.isdir(os.path.join(cwd, ".git")):
|
||||||
await login_with_credentials()
|
cwd = "/root/Prod/Solo_bot"
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["GIT_DIR"] = os.path.join(cwd, ".git")
|
||||||
|
env["GIT_WORK_TREE"] = cwd
|
||||||
|
|
||||||
|
try:
|
||||||
|
local_number = subprocess.check_output(
|
||||||
|
["git", "rev-list", "--count", "HEAD"],
|
||||||
|
cwd=cwd,
|
||||||
|
env=env
|
||||||
|
).decode().strip()
|
||||||
|
|
||||||
|
local_hash = subprocess.check_output(
|
||||||
|
["git", "rev-parse", "HEAD"],
|
||||||
|
cwd=cwd,
|
||||||
|
env=env
|
||||||
|
).decode().strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
branch = subprocess.check_output(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
cwd=cwd,
|
||||||
|
env=env
|
||||||
|
).decode().strip()
|
||||||
|
|
||||||
|
if branch == "HEAD":
|
||||||
|
describe = subprocess.check_output(
|
||||||
|
["git", "describe", "--tags", "--exact-match"],
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
stderr=subprocess.DEVNULL
|
||||||
|
).decode().strip()
|
||||||
|
|
||||||
|
if describe.startswith("v") or "release" in describe.lower():
|
||||||
|
branch = "main"
|
||||||
|
else:
|
||||||
|
branch = "dev"
|
||||||
|
except Exception:
|
||||||
|
branch = "dev"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"\n(Требуется обновление через CLI: {e})"
|
||||||
|
|
||||||
|
try:
|
||||||
|
remote_commit = subprocess.check_output(
|
||||||
|
["git", "ls-remote", "origin", f"refs/heads/{branch}"],
|
||||||
|
cwd=cwd,
|
||||||
|
env=env
|
||||||
|
).decode()
|
||||||
|
remote_hash = remote_commit.split()[0]
|
||||||
|
|
||||||
|
remote_number = subprocess.check_output(
|
||||||
|
["git", "rev-list", "--count", remote_hash],
|
||||||
|
cwd=cwd,
|
||||||
|
env=env
|
||||||
|
).decode().strip()
|
||||||
|
|
||||||
|
if local_hash == remote_hash:
|
||||||
|
return "\n(Актуальная версия)"
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"\n(commit <a href=\"{repo_url}/commit/{local_hash}\">"
|
||||||
|
f"#{local_number}</a> / actual commit "
|
||||||
|
f"<a href=\"{repo_url}/commit/{remote_hash}\">#{remote_number}</a>)"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
version = f"v4.3-Release{get_git_commit_number()}"
|
||||||
|
|
||||||
|
|
||||||
|
dp.message.filter(IsPrivateFilter())
|
||||||
|
dp.callback_query.filter(IsPrivateFilter())
|
||||||
|
|
||||||
|
|
||||||
|
@dp.errors(ExceptionTypeFilter(Exception))
|
||||||
|
async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
|
||||||
|
if isinstance(event.exception, TelegramForbiddenError):
|
||||||
|
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if isinstance(event.exception, TelegramBadRequest):
|
||||||
|
error_message = str(event.exception)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"query is too old and response timeout expired or query ID is invalid"
|
||||||
|
in error_message
|
||||||
|
or "message can't be deleted for everyone" in error_message
|
||||||
|
or "message to delete not found" in error_message
|
||||||
|
):
|
||||||
|
logger.warning("Отправляем стартовое меню.")
|
||||||
|
try:
|
||||||
|
from handlers.start import handle_start_callback_query, start_command
|
||||||
|
|
||||||
|
if event.update.message:
|
||||||
|
fsm_context = dp.fsm.get_context(
|
||||||
|
bot=bot,
|
||||||
|
chat_id=event.update.message.chat.id,
|
||||||
|
user_id=event.update.message.from_user.id,
|
||||||
|
)
|
||||||
|
await start_command(
|
||||||
|
event.update.message,
|
||||||
|
state=fsm_context,
|
||||||
|
session=None,
|
||||||
|
admin=False,
|
||||||
|
captcha=False,
|
||||||
|
)
|
||||||
|
elif event.update.callback_query:
|
||||||
|
fsm_context = dp.fsm.get_context(
|
||||||
|
bot=bot,
|
||||||
|
chat_id=event.update.callback_query.message.chat.id,
|
||||||
|
user_id=event.update.callback_query.from_user.id,
|
||||||
|
)
|
||||||
|
await handle_start_callback_query(
|
||||||
|
event.update.callback_query,
|
||||||
|
state=fsm_context,
|
||||||
|
session=None,
|
||||||
|
admin=False,
|
||||||
|
captcha=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.exception(f"Update: {event.update}\nException: {event.exception}")
|
||||||
|
|
||||||
|
if not ADMIN_ID:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
for admin_id in ADMIN_ID:
|
||||||
|
await bot.send_document(
|
||||||
|
chat_id=admin_id,
|
||||||
|
document=BufferedInputFile(
|
||||||
|
traceback.format_exc().encode(),
|
||||||
|
filename=f"error_{event.update.update_id}.txt",
|
||||||
|
),
|
||||||
|
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
|
||||||
|
)
|
||||||
|
|
||||||
|
from handlers.start import handle_start_callback_query, start_command
|
||||||
|
|
||||||
|
if event.update.message:
|
||||||
|
fsm_context = dp.fsm.get_context(
|
||||||
|
bot=bot,
|
||||||
|
chat_id=event.update.message.chat.id,
|
||||||
|
user_id=event.update.message.from_user.id,
|
||||||
|
)
|
||||||
|
await start_command(
|
||||||
|
event.update.message,
|
||||||
|
state=fsm_context,
|
||||||
|
session=None,
|
||||||
|
admin=False,
|
||||||
|
captcha=False,
|
||||||
|
)
|
||||||
|
elif event.update.callback_query:
|
||||||
|
fsm_context = dp.fsm.get_context(
|
||||||
|
bot=bot,
|
||||||
|
chat_id=event.update.callback_query.message.chat.id,
|
||||||
|
user_id=event.update.callback_query.from_user.id,
|
||||||
|
)
|
||||||
|
await handle_start_callback_query(
|
||||||
|
event.update.callback_query,
|
||||||
|
state=fsm_context,
|
||||||
|
session=None,
|
||||||
|
admin=False,
|
||||||
|
captcha=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
except TelegramBadRequest as exception:
|
||||||
|
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
|
||||||
|
except Exception as exception:
|
||||||
|
logger.error(f"Неожиданная ошибка в error handler: {exception}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
Executable
+469
@@ -0,0 +1,469 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.prompt import Confirm, Prompt
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from config import BOT_SERVICE
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdin.reconfigure(encoding="utf-8")
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not os.environ.get("LC_ALL", "").endswith("UTF-8"):
|
||||||
|
os.environ["LC_ALL"] = "en_US.UTF-8"
|
||||||
|
os.environ["LANG"] = "en_US.UTF-8"
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
BACK_DIR = os.path.expanduser("~/.solobot_backup")
|
||||||
|
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
|
||||||
|
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
IS_ROOT_DIR = PROJECT_DIR == "/root"
|
||||||
|
|
||||||
|
if IS_ROOT_DIR:
|
||||||
|
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
|
||||||
|
console.print(
|
||||||
|
"[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]"
|
||||||
|
)
|
||||||
|
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
|
||||||
|
console.print(
|
||||||
|
"[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]"
|
||||||
|
)
|
||||||
|
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
|
||||||
|
sys.exit(1)
|
||||||
|
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
|
||||||
|
SERVICE_NAME = BOT_SERVICE
|
||||||
|
|
||||||
|
|
||||||
|
def is_service_exists(service_name):
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "list-unit-files", service_name], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
return service_name in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def print_logo():
|
||||||
|
logo = Text(
|
||||||
|
"""
|
||||||
|
███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗
|
||||||
|
██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝
|
||||||
|
███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║
|
||||||
|
╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║
|
||||||
|
███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║
|
||||||
|
╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝
|
||||||
|
""",
|
||||||
|
style="bold cyan",
|
||||||
|
)
|
||||||
|
console.print(logo)
|
||||||
|
|
||||||
|
|
||||||
|
def backup_project():
|
||||||
|
console.print("[yellow]📦 Создаётся резервная копия проекта...[/yellow]")
|
||||||
|
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
|
||||||
|
subprocess.run(["rm", "-rf", BACK_DIR])
|
||||||
|
subprocess.run(["cp", "-r", PROJECT_DIR, BACK_DIR])
|
||||||
|
console.print(f"[green]✅ Бэкап сохранён в: {BACK_DIR}[/green]")
|
||||||
|
|
||||||
|
|
||||||
|
def fix_permissions():
|
||||||
|
"""Устанавливает корректные права на файлы проекта"""
|
||||||
|
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
|
||||||
|
try:
|
||||||
|
user = os.getenv("SUDO_USER") or os.getenv("USER")
|
||||||
|
if user:
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
|
||||||
|
|
||||||
|
console.print("[green]✅ Права успешно установлены[/green]")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
def install_rsync_if_needed():
|
||||||
|
if subprocess.run(["which", "rsync"], capture_output=True).returncode != 0:
|
||||||
|
console.print("[blue]📦 Установка rsync...[/blue]")
|
||||||
|
os.system("sudo apt update && sudo apt install -y rsync")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_project_dir_safe(update_buttons=False, update_img=False):
|
||||||
|
console.print("[yellow]🧹 Очистка проекта перед обновлением...[/yellow]")
|
||||||
|
preserved_paths = {
|
||||||
|
os.path.join(PROJECT_DIR, "config.py"),
|
||||||
|
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
|
||||||
|
os.path.join(PROJECT_DIR, ".git"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not update_buttons:
|
||||||
|
preserved_paths.add(os.path.join(PROJECT_DIR, "handlers", "buttons.py"))
|
||||||
|
if not update_img:
|
||||||
|
preserved_paths.add(os.path.join(PROJECT_DIR, "img"))
|
||||||
|
for root, _, files in os.walk(os.path.join(PROJECT_DIR, "img")):
|
||||||
|
for file in files:
|
||||||
|
preserved_paths.add(os.path.join(root, file))
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(PROJECT_DIR, topdown=False):
|
||||||
|
for file in files:
|
||||||
|
path = os.path.join(root, file)
|
||||||
|
if path in preserved_paths:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except PermissionError:
|
||||||
|
subprocess.run(["sudo", "rm", "-f", path])
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Не удалось удалить файл: {path}: {e}[/red]")
|
||||||
|
|
||||||
|
for dir in dirs:
|
||||||
|
dir_path = os.path.join(root, dir)
|
||||||
|
if os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "handlers"):
|
||||||
|
continue
|
||||||
|
if not update_img and os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "img"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.rmdir(dir_path)
|
||||||
|
except Exception:
|
||||||
|
subprocess.run(["sudo", "rm", "-rf", dir_path])
|
||||||
|
|
||||||
|
|
||||||
|
def install_git_if_needed():
|
||||||
|
if subprocess.run(["which", "git"], capture_output=True).returncode != 0:
|
||||||
|
console.print("[blue]Установка Git...[/blue]")
|
||||||
|
os.system("sudo apt update && sudo apt install -y git")
|
||||||
|
|
||||||
|
|
||||||
|
def install_dependencies():
|
||||||
|
console.print("[blue]🔧 Установка зависимостей...[/blue]")
|
||||||
|
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
|
||||||
|
try:
|
||||||
|
if not os.path.exists("venv"):
|
||||||
|
console.print(
|
||||||
|
"[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]"
|
||||||
|
)
|
||||||
|
subprocess.run("python3 -m venv venv", shell=True, check=True)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
|
||||||
|
shell=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
def restart_service():
|
||||||
|
if is_service_exists(SERVICE_NAME):
|
||||||
|
console.print("[blue]🚀 Перезапуск службы...[/blue]")
|
||||||
|
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
|
||||||
|
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
|
||||||
|
else:
|
||||||
|
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_version():
|
||||||
|
path = os.path.join(PROJECT_DIR, "bot.py")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_remote_version(branch="main"):
|
||||||
|
try:
|
||||||
|
url = f"https://raw.githubusercontent.com/Vladless/Solo_bot/{branch}/bot.py"
|
||||||
|
response = requests.get(url, timeout=10)
|
||||||
|
if response.status_code == 200:
|
||||||
|
for line in response.text.splitlines():
|
||||||
|
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_from_beta():
|
||||||
|
update_cli_launcher()
|
||||||
|
local_version = get_local_version()
|
||||||
|
remote_version = get_remote_version(branch="dev")
|
||||||
|
|
||||||
|
if local_version and remote_version:
|
||||||
|
console.print(
|
||||||
|
f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]"
|
||||||
|
)
|
||||||
|
if local_version == remote_version:
|
||||||
|
if not Confirm.ask("[yellow]❗ Версия актуальна. Обновить всё равно?[/yellow]"):
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
console.print("[red]⚠️ Не удалось определить версии.[/red]")
|
||||||
|
|
||||||
|
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
|
||||||
|
if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"):
|
||||||
|
return
|
||||||
|
|
||||||
|
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
|
||||||
|
update_img = Confirm.ask("[yellow]🖼 Обновлять папку img?[/yellow]", default=False)
|
||||||
|
|
||||||
|
backup_project()
|
||||||
|
install_git_if_needed()
|
||||||
|
install_rsync_if_needed()
|
||||||
|
|
||||||
|
os.chdir(PROJECT_DIR)
|
||||||
|
console.print("[cyan]📅 Клонируем временный репозиторий...[/cyan]")
|
||||||
|
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||||
|
|
||||||
|
if os.system(f"git clone --depth=1000000 -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
|
||||||
|
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
|
||||||
|
return
|
||||||
|
|
||||||
|
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
|
||||||
|
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
|
||||||
|
|
||||||
|
exclude_options = ""
|
||||||
|
if not update_img:
|
||||||
|
exclude_options += "--exclude=img "
|
||||||
|
if not update_buttons:
|
||||||
|
exclude_options += "--exclude=handlers/buttons.py "
|
||||||
|
|
||||||
|
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||||
|
|
||||||
|
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
|
||||||
|
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
|
||||||
|
|
||||||
|
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||||
|
|
||||||
|
fix_permissions()
|
||||||
|
install_dependencies()
|
||||||
|
restart_service()
|
||||||
|
console.print("[green]✅ Обновление с ветки dev завершено.[/green]")
|
||||||
|
|
||||||
|
|
||||||
|
def update_from_release():
|
||||||
|
update_cli_launcher()
|
||||||
|
if not Confirm.ask(
|
||||||
|
"[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
|
||||||
|
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
|
||||||
|
if not Confirm.ask("[red]❓ Вы точно хотите продолжить?[/red]"):
|
||||||
|
return
|
||||||
|
|
||||||
|
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
|
||||||
|
update_img = Confirm.ask("[yellow]🖼 Обновлять папку img?[/yellow]", default=False)
|
||||||
|
|
||||||
|
backup_project()
|
||||||
|
install_git_if_needed()
|
||||||
|
install_rsync_if_needed()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
"https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10
|
||||||
|
)
|
||||||
|
releases = response.json()[:3]
|
||||||
|
tag_choices = [r["tag_name"] for r in releases]
|
||||||
|
|
||||||
|
if not tag_choices:
|
||||||
|
raise ValueError("Не удалось получить список релизов")
|
||||||
|
|
||||||
|
console.print("\n[bold green]Доступные релизы:[/bold green]")
|
||||||
|
for idx, tag in enumerate(tag_choices, 1):
|
||||||
|
console.print(f"[cyan]{idx}.[/cyan] {tag}")
|
||||||
|
|
||||||
|
selected = Prompt.ask(
|
||||||
|
"[bold blue]Выберите номер релиза[/bold blue]",
|
||||||
|
choices=[str(i) for i in range(1, len(tag_choices) + 1)],
|
||||||
|
)
|
||||||
|
tag_name = tag_choices[int(selected) - 1]
|
||||||
|
|
||||||
|
if not Confirm.ask(
|
||||||
|
f"[yellow]🔁 Подтвердите установку релиза {tag_name}[/yellow]"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
|
||||||
|
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||||
|
subprocess.run(
|
||||||
|
f"git clone --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}",
|
||||||
|
shell=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print("[red]⚠️ Начинается перезапись файлов бота![/red]")
|
||||||
|
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
|
||||||
|
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
|
||||||
|
|
||||||
|
exclude_options = ""
|
||||||
|
if not update_img:
|
||||||
|
exclude_options += "--exclude=img "
|
||||||
|
if not update_buttons:
|
||||||
|
exclude_options += "--exclude=handlers/buttons.py "
|
||||||
|
|
||||||
|
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||||
|
|
||||||
|
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
|
||||||
|
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
|
||||||
|
|
||||||
|
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||||
|
|
||||||
|
fix_permissions()
|
||||||
|
install_dependencies()
|
||||||
|
restart_service()
|
||||||
|
console.print(f"[green]✅ Обновление до релиза {tag_name} завершено.[/green]")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
def show_update_menu():
|
||||||
|
if IS_ROOT_DIR:
|
||||||
|
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
|
||||||
|
console.print(
|
||||||
|
"[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
table = Table(title="Выберите способ обновления", title_style="bold green")
|
||||||
|
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||||
|
table.add_column("Источник", style="white")
|
||||||
|
table.add_row("1", "Обновить до BETA")
|
||||||
|
table.add_row("2", "Обновить до последнего релиза")
|
||||||
|
table.add_row("3", "Назад в меню")
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
choice = Prompt.ask("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
|
||||||
|
|
||||||
|
if choice == "1":
|
||||||
|
update_from_beta()
|
||||||
|
elif choice == "2":
|
||||||
|
update_from_release()
|
||||||
|
|
||||||
|
|
||||||
|
def show_menu():
|
||||||
|
table = Table(
|
||||||
|
title="Solobot CLI v0.2.0", title_style="bold magenta", header_style="bold blue"
|
||||||
|
)
|
||||||
|
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||||
|
table.add_column("Операция", style="white")
|
||||||
|
table.add_row("1", "Запустить бота (systemd)")
|
||||||
|
table.add_row("2", "Запустить напрямую: venv/bin/python main.py")
|
||||||
|
table.add_row("3", "Перезапустить бота (systemd)")
|
||||||
|
table.add_row("4", "Остановить бота (systemd)")
|
||||||
|
table.add_row("5", "Показать логи (80 строк)")
|
||||||
|
table.add_row("6", "Показать статус")
|
||||||
|
table.add_row("7", "Обновить Solobot")
|
||||||
|
table.add_row("8", "Обновить CLI лаунчер")
|
||||||
|
table.add_row("9", "Выход")
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
def update_cli_launcher():
|
||||||
|
"""Обновляет CLI лаунчер с dev ветки"""
|
||||||
|
console.print("[yellow]🔄 Обновление CLI лаунчера...[/yellow]")
|
||||||
|
try:
|
||||||
|
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
|
||||||
|
response = requests.get(url, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
with open(
|
||||||
|
os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
f.write(response.text)
|
||||||
|
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
|
||||||
|
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
|
||||||
|
else:
|
||||||
|
console.print("[red]❌ Не удалось загрузить новый CLI[/red]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]❌ Ошибка при обновлении CLI: {e}[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.chdir(PROJECT_DIR)
|
||||||
|
print_logo()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
show_menu()
|
||||||
|
choice = Prompt.ask(
|
||||||
|
"[bold blue]Введите номер действия[/bold blue]",
|
||||||
|
choices=[str(i) for i in range(1, 10)],
|
||||||
|
)
|
||||||
|
if choice == "1":
|
||||||
|
if is_service_exists(SERVICE_NAME):
|
||||||
|
subprocess.run(["sudo", "systemctl", "start", SERVICE_NAME])
|
||||||
|
else:
|
||||||
|
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||||
|
elif choice == "2":
|
||||||
|
if Confirm.ask(
|
||||||
|
"[green]Вы действительно хотите запустить main.py вручную?[/green]"
|
||||||
|
):
|
||||||
|
subprocess.run(["venv/bin/python", "main.py"])
|
||||||
|
elif choice == "3":
|
||||||
|
if is_service_exists(SERVICE_NAME):
|
||||||
|
if Confirm.ask(
|
||||||
|
"[yellow]Вы действительно хотите перезапустить бота?[/yellow]"
|
||||||
|
):
|
||||||
|
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
|
||||||
|
else:
|
||||||
|
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||||
|
elif choice == "4":
|
||||||
|
if is_service_exists(SERVICE_NAME):
|
||||||
|
if Confirm.ask(
|
||||||
|
"[red]Вы уверены, что хотите остановить бота?[/red]"
|
||||||
|
):
|
||||||
|
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
|
||||||
|
else:
|
||||||
|
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||||
|
elif choice == "5":
|
||||||
|
if is_service_exists(SERVICE_NAME):
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"sudo",
|
||||||
|
"journalctl",
|
||||||
|
"-u",
|
||||||
|
SERVICE_NAME,
|
||||||
|
"-n",
|
||||||
|
"80",
|
||||||
|
"--no-pager",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||||
|
elif choice == "6":
|
||||||
|
if is_service_exists(SERVICE_NAME):
|
||||||
|
subprocess.run(["sudo", "systemctl", "status", SERVICE_NAME])
|
||||||
|
else:
|
||||||
|
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||||
|
elif choice == "7":
|
||||||
|
show_update_menu()
|
||||||
|
elif choice == "8":
|
||||||
|
if Confirm.ask("[yellow]Обновить CLI лаунчер с dev ветки?[/yellow]"):
|
||||||
|
update_cli_launcher()
|
||||||
|
elif choice == "9":
|
||||||
|
if Confirm.ask("[yellow]Хотите обновить CLI перед выходом?[/yellow]"):
|
||||||
|
update_cli_launcher()
|
||||||
|
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n[bold red]⏹ Прерывание. Выход из CLI.[/bold red]")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
from config import API_URL
|
|
||||||
|
|
||||||
|
|
||||||
def add_client(session, client_id: str, email: str, tg_id: str, limit_ip: int, total_gb: int, expiry_time: int, enable: bool, flow: str):
|
|
||||||
url = f'{API_URL}/panel/api/inbounds/addClient'
|
|
||||||
|
|
||||||
email = email.lower()
|
|
||||||
|
|
||||||
client_data = {
|
|
||||||
"id": client_id,
|
|
||||||
"alterId": 0,
|
|
||||||
"email": email,
|
|
||||||
"limitIp": limit_ip,
|
|
||||||
"totalGB": total_gb,
|
|
||||||
"expiryTime": expiry_time,
|
|
||||||
"enable": enable,
|
|
||||||
"tgId": tg_id,
|
|
||||||
"subId": "",
|
|
||||||
"flow": flow,
|
|
||||||
}
|
|
||||||
|
|
||||||
settings = json.dumps({"clients": [client_data]})
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"id": 1,
|
|
||||||
"settings": settings
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
|
|
||||||
response = session.post(url, json=data, headers=headers)
|
|
||||||
|
|
||||||
print(f"Запрос на добавление клиента: {data}")
|
|
||||||
print(f"Статус ответа: {response.status_code}")
|
|
||||||
print(f"Ответ от сервера: {response.text}")
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
print(f"Клиент добавлен: email={email}")
|
|
||||||
return response.json()
|
|
||||||
else:
|
|
||||||
print(f"Ошибка при добавлении клиента: {response.status_code}, {response.text}")
|
|
||||||
|
|
||||||
def extend_client_key(session, tg_id, client_id, email: str, new_expiry_time: int) -> bool:
|
|
||||||
response = session.get(f"{API_URL}/panel/api/inbounds/getClientTraffics/{email}")
|
|
||||||
print(f"GET {response.url} Status: {response.status_code}")
|
|
||||||
print(f"GET Response: {response.text}")
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
print(f"Ошибка при получении данных клиента: {response.status_code} - {response.text}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
client_data = response.json().get("obj", {})
|
|
||||||
print(client_data)
|
|
||||||
|
|
||||||
if not client_data:
|
|
||||||
print("Не удалось получить данные клиента.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
current_expiry_time = client_data.get('expiryTime', 0)
|
|
||||||
|
|
||||||
if current_expiry_time == 0:
|
|
||||||
current_expiry_time = new_expiry_time
|
|
||||||
|
|
||||||
updated_expiry_time = max(current_expiry_time, new_expiry_time)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"id": 1,
|
|
||||||
"settings": json.dumps({
|
|
||||||
"clients": [
|
|
||||||
{
|
|
||||||
"id": client_id,
|
|
||||||
"alterId": 0,
|
|
||||||
"email": email.lower(),
|
|
||||||
"limitIp": 2,
|
|
||||||
"totalGB": 429496729600000,
|
|
||||||
"expiryTime": updated_expiry_time,
|
|
||||||
"enable": True,
|
|
||||||
"tgId": tg_id,
|
|
||||||
"subId": "",
|
|
||||||
"flow": "xtls-rprx-vision"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = session.post(f"{API_URL}/panel/api/inbounds/updateClient/{client_id}", json=payload, headers=headers)
|
|
||||||
print(f"POST {response.url} Status: {response.status_code}")
|
|
||||||
print(f"POST Request Data: {json.dumps(payload, indent=2)}")
|
|
||||||
print(f"POST Response: {response.text}")
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"Ошибка при продлении ключа: {response.status_code} - {response.text}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка запроса: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def delete_client(session, client_id: str) -> bool:
|
|
||||||
url = f"{API_URL}/panel/api/inbounds/1/delClient/{client_id}"
|
|
||||||
headers = {
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
response = session.post(url, headers=headers)
|
|
||||||
if response.status_code == 200:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"Ошибка при удалении клиента: {response.status_code} - {response.text}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка запроса: {e}")
|
|
||||||
return False
|
|
||||||
-98
@@ -1,98 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
from config import DATABASE_URL
|
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
await conn.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS connections (
|
|
||||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
|
||||||
balance REAL NOT NULL DEFAULT 0.0,
|
|
||||||
trial INTEGER NOT NULL DEFAULT 0
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
await conn.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS keys (
|
|
||||||
tg_id BIGINT NOT NULL,
|
|
||||||
client_id TEXT NOT NULL,
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
created_at BIGINT NOT NULL,
|
|
||||||
expiry_time BIGINT NOT NULL,
|
|
||||||
key TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (tg_id, client_id)
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
async def add_connection(tg_id: int, balance: float = 0.0, trial: int = 0):
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
await conn.execute('''
|
|
||||||
INSERT INTO connections (tg_id, balance, trial)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
''', tg_id, balance, trial)
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
async def check_connection_exists(tg_id: int):
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
exists = await conn.fetchval('''
|
|
||||||
SELECT EXISTS(SELECT 1 FROM connections WHERE tg_id = $1)
|
|
||||||
''', tg_id)
|
|
||||||
await conn.close()
|
|
||||||
return exists
|
|
||||||
|
|
||||||
async def store_key(tg_id: int, client_id: str, email: str, expiry_time: int, key: str):
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
await conn.execute('''
|
|
||||||
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
''', tg_id, client_id, email, int(datetime.utcnow().timestamp() * 1000), expiry_time, key)
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
async def get_keys(tg_id: int):
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
records = await conn.fetch('''
|
|
||||||
SELECT client_id, email, created_at, key
|
|
||||||
FROM keys
|
|
||||||
WHERE tg_id = $1
|
|
||||||
''', tg_id)
|
|
||||||
await conn.close()
|
|
||||||
return records
|
|
||||||
|
|
||||||
async def has_active_key(tg_id: int) -> bool:
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE tg_id = $1", tg_id)
|
|
||||||
await conn.close()
|
|
||||||
return count > 0
|
|
||||||
|
|
||||||
async def get_balance(tg_id: int) -> float:
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
balance = await conn.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id)
|
|
||||||
await conn.close()
|
|
||||||
return balance if balance is not None else 0.0
|
|
||||||
|
|
||||||
async def update_balance(tg_id: int, amount: float):
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
await conn.execute('''
|
|
||||||
UPDATE connections
|
|
||||||
SET balance = balance + $1
|
|
||||||
WHERE tg_id = $2
|
|
||||||
''', amount, tg_id)
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
async def get_trial(tg_id: int) -> int:
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
trial = await conn.fetchval("SELECT trial FROM connections WHERE tg_id = $1", tg_id)
|
|
||||||
await conn.close()
|
|
||||||
return trial if trial is not None else 0
|
|
||||||
|
|
||||||
async def get_key_count(tg_id: int) -> int:
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
count = await conn.fetchval('SELECT COUNT(*) FROM keys WHERE tg_id = $1', tg_id)
|
|
||||||
await conn.close()
|
|
||||||
return count if count is not None else 0
|
|
||||||
|
|
||||||
async def get_all_users(conn):
|
|
||||||
return await conn.fetch('SELECT tg_id FROM connections')
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from .bans import *
|
||||||
|
from .coupons import *
|
||||||
|
from .db import async_session_maker
|
||||||
|
from .gifts import *
|
||||||
|
from .hot_leads import *
|
||||||
|
from .init_db import *
|
||||||
|
from .keys import *
|
||||||
|
from .notifications import *
|
||||||
|
from .payments import *
|
||||||
|
from .referrals import *
|
||||||
|
from .servers import *
|
||||||
|
from .statistics import *
|
||||||
|
from .tariffs import *
|
||||||
|
from .temporary_data import *
|
||||||
|
from .tracking_sources import *
|
||||||
|
from .users import *
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import BlockedUser
|
||||||
|
|
||||||
|
|
||||||
|
async def create_blocked_user(session: AsyncSession, tg_id: int):
|
||||||
|
stmt = (
|
||||||
|
insert(BlockedUser)
|
||||||
|
.values(tg_id=tg_id)
|
||||||
|
.on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import case, delete, func, insert, select, update
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Coupon, CouponUsage
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def create_coupon(
|
||||||
|
session: AsyncSession, code: str, amount: int, usage_limit: int, days: int = None
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
|
||||||
|
if exists:
|
||||||
|
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
insert(Coupon).values(
|
||||||
|
code=code,
|
||||||
|
amount=amount,
|
||||||
|
usage_limit=usage_limit,
|
||||||
|
usage_count=0,
|
||||||
|
is_used=False,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"[Coupon] ✅ Купон {code} успешно создан.")
|
||||||
|
return True
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"[Coupon] ❌ Ошибка при создании купона {code}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_coupon_by_code(session: AsyncSession, code: str) -> Coupon | None:
|
||||||
|
stmt = select(Coupon).where(Coupon.code == code)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_coupons(
|
||||||
|
session: AsyncSession, page: int = 1, per_page: int = 10
|
||||||
|
) -> dict:
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
stmt = select(Coupon).order_by(Coupon.id.desc()).offset(offset).limit(per_page)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
coupons = result.scalars().all()
|
||||||
|
|
||||||
|
count_stmt = select(func.count()).select_from(Coupon)
|
||||||
|
total = await session.scalar(count_stmt)
|
||||||
|
pages = -(-total // per_page)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"coupons": [c.to_dict() for c in coupons],
|
||||||
|
"total": total,
|
||||||
|
"pages": pages,
|
||||||
|
"current_page": page,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_coupon(session: AsyncSession, code: str) -> bool:
|
||||||
|
result = await session.execute(select(Coupon).where(Coupon.code == code))
|
||||||
|
coupon = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not coupon:
|
||||||
|
logger.info(f"❌ Купон {code} не найден")
|
||||||
|
return False
|
||||||
|
|
||||||
|
await session.execute(delete(CouponUsage).where(CouponUsage.coupon_id == coupon.id))
|
||||||
|
|
||||||
|
await session.delete(coupon)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"🗑 Купон {code} удалён вместе с его использованиями")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def create_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int):
|
||||||
|
try:
|
||||||
|
stmt = insert(CouponUsage).values(
|
||||||
|
coupon_id=coupon_id, user_id=user_id, used_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"✅ Купон {coupon_id} использован пользователем {user_id}")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при сохранении использования купона: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_coupon_usage(
|
||||||
|
session: AsyncSession, coupon_id: int, user_id: int
|
||||||
|
) -> bool:
|
||||||
|
stmt = select(CouponUsage).where(
|
||||||
|
CouponUsage.coupon_id == coupon_id, CouponUsage.user_id == user_id
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def update_coupon_usage_count(session: AsyncSession, coupon_id: int):
|
||||||
|
try:
|
||||||
|
await session.execute(
|
||||||
|
update(Coupon)
|
||||||
|
.where(Coupon.id == coupon_id)
|
||||||
|
.values(
|
||||||
|
usage_count=Coupon.usage_count + 1,
|
||||||
|
is_used=case(
|
||||||
|
(Coupon.usage_count + 1 >= Coupon.usage_limit, True), else_=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"🔁 Обновлён счётчик купона {coupon_id}")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при обновлении купона {coupon_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
from config import DATABASE_URL
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
|
||||||
|
|
||||||
|
async_session_maker = async_sessionmaker(
|
||||||
|
bind=engine, expire_on_commit=False, class_=AsyncSession
|
||||||
|
)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import insert
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Gift
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def store_gift_link(
|
||||||
|
session: AsyncSession,
|
||||||
|
gift_id: str,
|
||||||
|
sender_tg_id: int,
|
||||||
|
selected_months: int,
|
||||||
|
expiry_time: datetime,
|
||||||
|
gift_link: str,
|
||||||
|
tariff_id: int | None = None,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
stmt = insert(Gift).values(
|
||||||
|
gift_id=gift_id,
|
||||||
|
sender_tg_id=sender_tg_id,
|
||||||
|
recipient_tg_id=None,
|
||||||
|
selected_months=selected_months,
|
||||||
|
expiry_time=expiry_time,
|
||||||
|
gift_link=gift_link,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
is_used=False,
|
||||||
|
tariff_id=tariff_id,
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"🎁 Подарок {gift_id} сохранён (tariff_id={tariff_id})")
|
||||||
|
return True
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
return False
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Key, Payment
|
||||||
|
|
||||||
|
|
||||||
|
async def get_hot_leads(session: AsyncSession):
|
||||||
|
"""
|
||||||
|
Возвращает пользователей, у которых есть успешные оплаты, но нет активных ключей.
|
||||||
|
"""
|
||||||
|
subquery = (
|
||||||
|
select(Key.tg_id)
|
||||||
|
.where(Key.expiry_time > func.extract("epoch", func.now()) * 1000)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Payment.tg_id)
|
||||||
|
.distinct()
|
||||||
|
.where(Payment.amount > 0)
|
||||||
|
.where(~Payment.tg_id.in_(subquery))
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [row.tg_id for row in result]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from database.db import engine
|
||||||
|
from database.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select, text, update
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Key, User
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def store_key(
|
||||||
|
session: AsyncSession,
|
||||||
|
tg_id: int,
|
||||||
|
client_id: str,
|
||||||
|
email: str,
|
||||||
|
expiry_time: int,
|
||||||
|
key: str,
|
||||||
|
server_id: str,
|
||||||
|
remnawave_link: str = None,
|
||||||
|
tariff_id: int = None,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
exists = await session.execute(
|
||||||
|
select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||||
|
)
|
||||||
|
if exists.scalar_one_or_none():
|
||||||
|
logger.info(
|
||||||
|
f"[Store Key] Ключ уже существует — пропускаем: tg_id={tg_id}, client_id={client_id}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_key = Key(
|
||||||
|
tg_id=tg_id,
|
||||||
|
client_id=client_id,
|
||||||
|
email=email,
|
||||||
|
created_at=int(datetime.utcnow().timestamp() * 1000),
|
||||||
|
expiry_time=expiry_time,
|
||||||
|
key=key,
|
||||||
|
server_id=server_id,
|
||||||
|
remnawave_link=remnawave_link,
|
||||||
|
tariff_id=tariff_id,
|
||||||
|
)
|
||||||
|
session.add(new_key)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"✅ Ключ сохранён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}"
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_keys(session: AsyncSession, tg_id: int):
|
||||||
|
result = await session.execute(select(Key).where(Key.tg_id == tg_id))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_keys(session: AsyncSession):
|
||||||
|
result = await session.execute(select(Key))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str):
|
||||||
|
stmt = select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_key_details(session: AsyncSession, email: str) -> dict | None:
|
||||||
|
stmt = (
|
||||||
|
select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
row = result.first()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
key, user = row
|
||||||
|
expiry_date = datetime.utcfromtimestamp(key.expiry_time / 1000)
|
||||||
|
current_date = datetime.utcnow()
|
||||||
|
time_left = expiry_date - current_date
|
||||||
|
|
||||||
|
if time_left.total_seconds() <= 0:
|
||||||
|
days_left_message = "<b>Ключ истек.</b>"
|
||||||
|
elif time_left.days > 0:
|
||||||
|
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
|
||||||
|
else:
|
||||||
|
hours_left = time_left.seconds // 3600
|
||||||
|
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"key": key.key,
|
||||||
|
"remnawave_link": key.remnawave_link,
|
||||||
|
"server_id": key.server_id,
|
||||||
|
"created_at": key.created_at,
|
||||||
|
"expiry_time": key.expiry_time,
|
||||||
|
"client_id": key.client_id,
|
||||||
|
"tg_id": user.tg_id,
|
||||||
|
"email": key.email,
|
||||||
|
"is_frozen": key.is_frozen,
|
||||||
|
"balance": user.balance,
|
||||||
|
"alias": key.alias,
|
||||||
|
"expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
|
||||||
|
"days_left_message": days_left_message,
|
||||||
|
"link": key.key or key.remnawave_link,
|
||||||
|
"cluster_name": key.server_id,
|
||||||
|
"location_name": key.server_id,
|
||||||
|
"tariff_id": key.tariff_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_key_count(session: AsyncSession, tg_id: int) -> int:
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count()).select_from(Key).where(Key.tg_id == tg_id)
|
||||||
|
)
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_key(session: AsyncSession, identifier: int | str):
|
||||||
|
stmt = delete(Key).where(
|
||||||
|
Key.tg_id == identifier
|
||||||
|
if str(identifier).isdigit()
|
||||||
|
else Key.client_id == identifier
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Ключ с идентификатором {identifier} удалён")
|
||||||
|
|
||||||
|
|
||||||
|
async def update_key_expiry(
|
||||||
|
session: AsyncSession, client_id: str, new_expiry_time: int
|
||||||
|
):
|
||||||
|
await session.execute(
|
||||||
|
update(Key)
|
||||||
|
.where(Key.client_id == client_id)
|
||||||
|
.values(expiry_time=new_expiry_time, notified=False, notified_24h=False)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Срок действия ключа {client_id} обновлён до {new_expiry_time}")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_client_id_by_email(session: AsyncSession, email: str):
|
||||||
|
result = await session.execute(select(Key.client_id).where(Key.email == email))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_key_notified(session: AsyncSession, tg_id: int, client_id: str):
|
||||||
|
await session.execute(
|
||||||
|
update(Key)
|
||||||
|
.where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||||
|
.values(notified=True)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_key_as_frozen(
|
||||||
|
session: AsyncSession, tg_id: int, client_id: str, time_left: int
|
||||||
|
):
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE keys
|
||||||
|
SET expiry_time = :expiry,
|
||||||
|
is_frozen = TRUE
|
||||||
|
WHERE tg_id = :tg_id
|
||||||
|
AND client_id = :client_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_key_as_unfrozen(
|
||||||
|
session: AsyncSession, tg_id: int, client_id: str, new_expiry_time: int
|
||||||
|
):
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE keys
|
||||||
|
SET expiry_time = :expiry,
|
||||||
|
is_frozen = FALSE
|
||||||
|
WHERE tg_id = :tg_id
|
||||||
|
AND client_id = :client_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_key_tariff(session: AsyncSession, client_id: str, tariff_id: int):
|
||||||
|
await session.execute(
|
||||||
|
update(Key)
|
||||||
|
.where(Key.client_id == client_id)
|
||||||
|
.values(tariff_id=tariff_id)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Тариф ключа {client_id} обновлён на {tariff_id}")
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class DictLikeMixin:
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return getattr(self, key)
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return getattr(self, key, default)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
column.name: getattr(self, column.name) for column in self.__table__.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class User(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
tg_id = Column(BigInteger, primary_key=True)
|
||||||
|
username = Column(String)
|
||||||
|
first_name = Column(String)
|
||||||
|
last_name = Column(String)
|
||||||
|
language_code = Column(String)
|
||||||
|
is_bot = Column(Boolean, default=False)
|
||||||
|
balance = Column(Float, default=0.0)
|
||||||
|
trial = Column(Integer, default=0)
|
||||||
|
source_code = Column(String, ForeignKey("tracking_sources.code"))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Key(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "keys"
|
||||||
|
|
||||||
|
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=False)
|
||||||
|
client_id = Column(String, primary_key=True)
|
||||||
|
email = Column(String, unique=True)
|
||||||
|
created_at = Column(BigInteger)
|
||||||
|
expiry_time = Column(BigInteger)
|
||||||
|
key = Column(String)
|
||||||
|
server_id = Column(String)
|
||||||
|
remnawave_link = Column(String)
|
||||||
|
tariff_id = Column(Integer, ForeignKey("tariffs.id"))
|
||||||
|
is_frozen = Column(Boolean, default=False)
|
||||||
|
alias = Column(String)
|
||||||
|
notified = Column(Boolean, default=False)
|
||||||
|
notified_24h = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Tariff(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "tariffs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String)
|
||||||
|
group_code = Column(String)
|
||||||
|
duration_days = Column(Integer)
|
||||||
|
price_rub = Column(Integer)
|
||||||
|
traffic_limit = Column(BigInteger, nullable=True)
|
||||||
|
device_limit = Column(Integer, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Server(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "servers"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
cluster_name = Column(String)
|
||||||
|
server_name = Column(String, unique=True)
|
||||||
|
api_url = Column(String)
|
||||||
|
subscription_url = Column(String)
|
||||||
|
inbound_id = Column(String)
|
||||||
|
panel_type = Column(String)
|
||||||
|
max_keys = Column(Integer)
|
||||||
|
tariff_group = Column(String)
|
||||||
|
enabled = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Payment(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "payments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
|
||||||
|
amount = Column(Float)
|
||||||
|
payment_system = Column(String)
|
||||||
|
status = Column(String)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Coupon(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "coupons"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
code = Column(String, unique=True)
|
||||||
|
amount = Column(Integer)
|
||||||
|
usage_limit = Column(Integer)
|
||||||
|
usage_count = Column(Integer, default=0)
|
||||||
|
is_used = Column(Boolean, default=False)
|
||||||
|
days = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CouponUsage(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "coupon_usages"
|
||||||
|
|
||||||
|
coupon_id = Column(Integer, ForeignKey("coupons.id"), primary_key=True)
|
||||||
|
user_id = Column(BigInteger, primary_key=True)
|
||||||
|
used_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Referral(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "referrals"
|
||||||
|
|
||||||
|
referred_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
|
||||||
|
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
|
||||||
|
reward_issued = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
|
||||||
|
notification_type = Column(String, primary_key=True)
|
||||||
|
last_notification_time = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Gift(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "gifts"
|
||||||
|
|
||||||
|
gift_id = Column(String, primary_key=True)
|
||||||
|
sender_tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
|
||||||
|
recipient_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True)
|
||||||
|
selected_months = Column(Integer)
|
||||||
|
expiry_time = Column(DateTime)
|
||||||
|
gift_link = Column(String)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
is_used = Column(Boolean, default=False)
|
||||||
|
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
|
||||||
|
|
||||||
|
|
||||||
|
class ManualBan(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "manual_bans"
|
||||||
|
|
||||||
|
tg_id = Column(BigInteger, primary_key=True)
|
||||||
|
banned_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
||||||
|
reason = Column(Text)
|
||||||
|
banned_by = Column(BigInteger)
|
||||||
|
until = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TemporaryData(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "temporary_data"
|
||||||
|
|
||||||
|
tg_id = Column(BigInteger, primary_key=True)
|
||||||
|
state = Column(String)
|
||||||
|
data = Column(JSON)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedUser(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "blocked_users"
|
||||||
|
|
||||||
|
tg_id = Column(BigInteger, primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingSource(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "tracking_sources"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String)
|
||||||
|
code = Column(String, unique=True)
|
||||||
|
type = Column(String)
|
||||||
|
created_by = Column(BigInteger)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Log(DictLikeMixin, Base):
|
||||||
|
__tablename__ = "logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
event_type = Column(String)
|
||||||
|
tg_id = Column(BigInteger)
|
||||||
|
details = Column(JSON)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import and_, delete, func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Key, Notification, User
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def add_notification(session: AsyncSession, tg_id: int, notification_type: str):
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
insert(Notification)
|
||||||
|
.values(
|
||||||
|
tg_id=tg_id,
|
||||||
|
notification_type=notification_type,
|
||||||
|
last_notification_time=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[Notification.tg_id, Notification.notification_type],
|
||||||
|
set_={"last_notification_time": datetime.utcnow()},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"✅ Добавлено уведомление {notification_type} для пользователя {tg_id}"
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при добавлении уведомления: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_notification(
|
||||||
|
session: AsyncSession, tg_id: int, notification_type: str
|
||||||
|
):
|
||||||
|
await session.execute(
|
||||||
|
delete(Notification).where(
|
||||||
|
Notification.tg_id == tg_id,
|
||||||
|
Notification.notification_type == notification_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"🗑 Уведомление {notification_type} для пользователя {tg_id} удалено")
|
||||||
|
|
||||||
|
|
||||||
|
async def check_notification_time(
|
||||||
|
session: AsyncSession, tg_id: int, notification_type: str, hours: int = 12
|
||||||
|
) -> bool:
|
||||||
|
stmt = select(Notification.last_notification_time).where(
|
||||||
|
Notification.tg_id == tg_id, Notification.notification_type == notification_type
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
last_time = result.scalar_one_or_none()
|
||||||
|
if not last_time:
|
||||||
|
return True
|
||||||
|
return datetime.utcnow() - last_time > timedelta(hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_last_notification_time(
|
||||||
|
session: AsyncSession, tg_id: int, notification_type: str
|
||||||
|
) -> int | None:
|
||||||
|
stmt = select(Notification.last_notification_time).where(
|
||||||
|
Notification.tg_id == tg_id, Notification.notification_type == notification_type
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
ts = result.scalar_one_or_none()
|
||||||
|
if ts:
|
||||||
|
return int(ts.timestamp() * 1000)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_notifications_bulk(
|
||||||
|
session: AsyncSession,
|
||||||
|
notification_type: str,
|
||||||
|
hours: int,
|
||||||
|
tg_ids: list[int] = None,
|
||||||
|
emails: list[str] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from database.models import User, Key, Notification, BlockedUser
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
subq_last_notification = (
|
||||||
|
select(
|
||||||
|
Notification.tg_id,
|
||||||
|
func.max(Notification.last_notification_time).label("last_notification_time")
|
||||||
|
)
|
||||||
|
.where(Notification.notification_type == notification_type)
|
||||||
|
.group_by(Notification.tg_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
User.tg_id,
|
||||||
|
Key.email,
|
||||||
|
User.username,
|
||||||
|
User.first_name,
|
||||||
|
User.last_name,
|
||||||
|
subq_last_notification.c.last_notification_time,
|
||||||
|
)
|
||||||
|
.outerjoin(Key, Key.tg_id == User.tg_id)
|
||||||
|
.outerjoin(subq_last_notification, subq_last_notification.c.tg_id == User.tg_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if notification_type == "inactive_trial":
|
||||||
|
stmt = stmt.where(
|
||||||
|
and_(
|
||||||
|
User.trial.in_([0, -1]),
|
||||||
|
~User.tg_id.in_(select(BlockedUser.tg_id)),
|
||||||
|
~User.tg_id.in_(select(Key.tg_id.distinct()))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if tg_ids:
|
||||||
|
stmt = stmt.where(User.tg_id.in_(tg_ids))
|
||||||
|
if emails:
|
||||||
|
stmt = stmt.where(Key.email.in_(emails))
|
||||||
|
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
users = []
|
||||||
|
|
||||||
|
for row in result:
|
||||||
|
last_time = row.last_notification_time
|
||||||
|
can_notify = not last_time or (now - last_time > timedelta(hours=hours))
|
||||||
|
|
||||||
|
if can_notify:
|
||||||
|
users.append({
|
||||||
|
"tg_id": row.tg_id,
|
||||||
|
"email": row.email,
|
||||||
|
"username": row.username,
|
||||||
|
"first_name": row.first_name,
|
||||||
|
"last_name": row.last_name,
|
||||||
|
"last_notification_time": (
|
||||||
|
int(last_time.timestamp() * 1000) if last_time else None
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Найдено {len(users)} пользователей, готовых к уведомлению типа {notification_type}")
|
||||||
|
return users
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при массовой проверке уведомлений типа {notification_type}: {e}")
|
||||||
|
return []
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import insert, select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Payment
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def add_payment(
|
||||||
|
session: AsyncSession, tg_id: int, amount: float, payment_system: str
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
stmt = insert(Payment).values(
|
||||||
|
tg_id=tg_id,
|
||||||
|
amount=amount,
|
||||||
|
payment_system=payment_system,
|
||||||
|
status="success",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"✅ Успешно добавлен платёж: {tg_id}, {amount}₽ через {payment_system}"
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при добавлении платежа: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def get_last_payments(session: AsyncSession, tg_id: int, limit: int = 3):
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Payment)
|
||||||
|
.where(Payment.tg_id == tg_id)
|
||||||
|
.order_by(Payment.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
payments = result.scalars().all()
|
||||||
|
logger.info(
|
||||||
|
f"✅ Получены последние платежи пользователя {tg_id}, всего: {len(payments)}"
|
||||||
|
)
|
||||||
|
return [dict(p.__dict__) for p in payments]
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при получении платежей пользователя {tg_id}: {e}")
|
||||||
|
return []
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
from sqlalchemy import and_, desc, func, insert, select, text, update
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES
|
||||||
|
from database.models import Referral
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def add_referral(session: AsyncSession, referred_tg_id: int, referrer_tg_id: int):
|
||||||
|
try:
|
||||||
|
if referred_tg_id == referrer_tg_id:
|
||||||
|
logger.warning(f"⚠️ Попытка самореферала: {referred_tg_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
stmt = insert(Referral).values(
|
||||||
|
referred_tg_id=referred_tg_id, referrer_tg_id=referrer_tg_id
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"✅ Добавлена реферальная связь: {referred_tg_id} → {referrer_tg_id}"
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при добавлении реферала: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def get_referral_by_referred_id(
|
||||||
|
session: AsyncSession, referred_tg_id: int
|
||||||
|
) -> dict | None:
|
||||||
|
stmt = select(Referral).where(Referral.referred_tg_id == referred_tg_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
return dict(row.__dict__) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_total_referrals(session: AsyncSession, referrer_tg_id: int) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Referral)
|
||||||
|
.where(Referral.referrer_tg_id == referrer_tg_id)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_referrals(session: AsyncSession, referrer_tg_id: int) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Referral)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Referral.referrer_tg_id == referrer_tg_id,
|
||||||
|
Referral.reward_issued is True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_referral_reward_issued(session: AsyncSession, referred_tg_id: int):
|
||||||
|
await session.execute(
|
||||||
|
update(Referral)
|
||||||
|
.where(Referral.referred_tg_id == referred_tg_id)
|
||||||
|
.values(reward_issued=True)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_total_referral_bonus(
|
||||||
|
session: AsyncSession, referrer_tg_id: int, max_levels: int
|
||||||
|
) -> float:
|
||||||
|
if CHECK_REFERRAL_REWARD_ISSUED:
|
||||||
|
bonus_cte = """
|
||||||
|
WITH RECURSIVE
|
||||||
|
referral_levels AS (
|
||||||
|
SELECT
|
||||||
|
referred_tg_id,
|
||||||
|
referrer_tg_id,
|
||||||
|
1 AS level
|
||||||
|
FROM referrals
|
||||||
|
WHERE referrer_tg_id = :tg_id AND reward_issued = TRUE
|
||||||
|
|
||||||
|
UNION
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
r.referred_tg_id,
|
||||||
|
r.referrer_tg_id,
|
||||||
|
rl.level + 1
|
||||||
|
FROM referrals r
|
||||||
|
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
|
||||||
|
WHERE rl.level < :max_levels AND r.reward_issued = TRUE
|
||||||
|
),
|
||||||
|
earliest_payments AS (
|
||||||
|
SELECT DISTINCT ON (tg_id) tg_id, amount, created_at
|
||||||
|
FROM payments
|
||||||
|
WHERE status = 'success'
|
||||||
|
ORDER BY tg_id, created_at
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
bonus_query = (
|
||||||
|
bonus_cte
|
||||||
|
+ f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
{
|
||||||
|
" ".join([
|
||||||
|
f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]} * ep.amount"
|
||||||
|
if isinstance(REFERRAL_BONUS_PERCENTAGES[level], float)
|
||||||
|
else f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}"
|
||||||
|
for level in REFERRAL_BONUS_PERCENTAGES
|
||||||
|
])
|
||||||
|
}
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0) AS total_bonus
|
||||||
|
FROM referral_levels rl
|
||||||
|
JOIN earliest_payments ep ON rl.referred_tg_id = ep.tg_id
|
||||||
|
WHERE rl.level <= :max_levels
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
bonus_cte = """
|
||||||
|
WITH RECURSIVE
|
||||||
|
referral_levels AS (
|
||||||
|
SELECT
|
||||||
|
referred_tg_id,
|
||||||
|
referrer_tg_id,
|
||||||
|
1 AS level
|
||||||
|
FROM referrals
|
||||||
|
WHERE referrer_tg_id = :tg_id
|
||||||
|
|
||||||
|
UNION
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
r.referred_tg_id,
|
||||||
|
r.referrer_tg_id,
|
||||||
|
rl.level + 1
|
||||||
|
FROM referrals r
|
||||||
|
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
|
||||||
|
WHERE rl.level < :max_levels
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
bonus_query = (
|
||||||
|
bonus_cte
|
||||||
|
+ f"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
{
|
||||||
|
" ".join([
|
||||||
|
f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]} * p.amount"
|
||||||
|
if isinstance(REFERRAL_BONUS_PERCENTAGES[level], float)
|
||||||
|
else f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}"
|
||||||
|
for level in REFERRAL_BONUS_PERCENTAGES
|
||||||
|
])
|
||||||
|
}
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0) AS total_bonus
|
||||||
|
FROM referral_levels rl
|
||||||
|
JOIN payments p ON rl.referred_tg_id = p.tg_id
|
||||||
|
WHERE p.status = 'success' AND rl.level <= :max_levels
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
text(bonus_query), {"tg_id": referrer_tg_id, "max_levels": max_levels}
|
||||||
|
)
|
||||||
|
total_bonus_raw = result.scalar()
|
||||||
|
total_bonus = round(float(total_bonus_raw or 0), 2)
|
||||||
|
|
||||||
|
logger.debug(f"Получена общая сумма бонусов от рефералов: {total_bonus}")
|
||||||
|
return total_bonus
|
||||||
|
|
||||||
|
|
||||||
|
async def get_referrals_by_level(
|
||||||
|
session: AsyncSession, referrer_tg_id: int, max_levels: int
|
||||||
|
) -> dict:
|
||||||
|
query = """
|
||||||
|
WITH RECURSIVE referral_levels AS (
|
||||||
|
SELECT referred_tg_id, referrer_tg_id, 1 AS level
|
||||||
|
FROM referrals
|
||||||
|
WHERE referrer_tg_id = :referrer_tg_id
|
||||||
|
UNION
|
||||||
|
SELECT r.referred_tg_id, r.referrer_tg_id, rl.level + 1
|
||||||
|
FROM referrals r
|
||||||
|
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
|
||||||
|
WHERE rl.level < :max_levels
|
||||||
|
)
|
||||||
|
SELECT level,
|
||||||
|
COUNT(*) AS level_count,
|
||||||
|
COUNT(CASE WHEN reward_issued THEN 1 END) AS active_level_count
|
||||||
|
FROM referral_levels rl
|
||||||
|
JOIN referrals r ON rl.referred_tg_id = r.referred_tg_id
|
||||||
|
GROUP BY level
|
||||||
|
ORDER BY level
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
text(query), {"referrer_tg_id": referrer_tg_id, "max_levels": max_levels}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
row["level"]: {
|
||||||
|
"total": row["level_count"],
|
||||||
|
"active": row["active_level_count"],
|
||||||
|
}
|
||||||
|
for row in result.mappings()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_referral_stats(session: AsyncSession, referrer_tg_id: int):
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"[ReferralStats] Получение статистики для пользователя {referrer_tg_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_referrals = await get_total_referrals(session, referrer_tg_id)
|
||||||
|
active_referrals = await get_active_referrals(session, referrer_tg_id)
|
||||||
|
max_levels = len(REFERRAL_BONUS_PERCENTAGES)
|
||||||
|
referrals_by_level = await get_referrals_by_level(
|
||||||
|
session, referrer_tg_id, max_levels
|
||||||
|
)
|
||||||
|
total_referral_bonus = await get_total_referral_bonus(
|
||||||
|
session, referrer_tg_id, max_levels
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_referrals": total_referrals,
|
||||||
|
"active_referrals": active_referrals,
|
||||||
|
"referrals_by_level": referrals_by_level,
|
||||||
|
"total_referral_bonus": total_referral_bonus,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[ReferralStats] Ошибка при получении статистики для пользователя {referrer_tg_id}: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_referral_count(session: AsyncSession, tg_id: int) -> int:
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Referral)
|
||||||
|
.where(Referral.referrer_tg_id == tg_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one() or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_referral_position(session: AsyncSession, referral_count: int) -> int:
|
||||||
|
subq = (
|
||||||
|
select(Referral.referrer_tg_id)
|
||||||
|
.group_by(Referral.referrer_tg_id)
|
||||||
|
.having(func.count() > referral_count)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
query = select(func.count()).select_from(subq)
|
||||||
|
result = await session.execute(query)
|
||||||
|
count = result.scalar() or 0
|
||||||
|
return count + 1
|
||||||
|
|
||||||
|
|
||||||
|
async def get_top_referrals(session: AsyncSession, limit: int = 5):
|
||||||
|
query = (
|
||||||
|
select(Referral.referrer_tg_id, func.count().label("referral_count"))
|
||||||
|
.group_by(Referral.referrer_tg_id)
|
||||||
|
.order_by(desc("referral_count"))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return [
|
||||||
|
{"referrer_tg_id": row.referrer_tg_id, "referral_count": row.referral_count}
|
||||||
|
for row in result.all()
|
||||||
|
]
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from sqlalchemy import delete, insert, select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Server
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def create_server(
|
||||||
|
session: AsyncSession,
|
||||||
|
cluster_name: str,
|
||||||
|
server_name: str,
|
||||||
|
api_url: str,
|
||||||
|
subscription_url: str,
|
||||||
|
inbound_id: str,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
stmt = insert(Server).values(
|
||||||
|
cluster_name=cluster_name,
|
||||||
|
server_name=server_name,
|
||||||
|
api_url=api_url,
|
||||||
|
subscription_url=subscription_url,
|
||||||
|
inbound_id=inbound_id,
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"✅ Сервер {server_name} добавлен в кластер {cluster_name}")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при добавлении сервера {server_name}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_server(session: AsyncSession, server_name: str):
|
||||||
|
try:
|
||||||
|
stmt = delete(Server).where(Server.server_name == server_name)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"🗑 Сервер {server_name} удалён")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при удалении сервера {server_name}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def get_servers(session: AsyncSession, include_enabled: bool = False) -> dict:
|
||||||
|
try:
|
||||||
|
stmt = select(Server)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
servers = result.scalars().all()
|
||||||
|
|
||||||
|
grouped = {}
|
||||||
|
for s in servers:
|
||||||
|
if not include_enabled and not s.enabled:
|
||||||
|
continue
|
||||||
|
cluster = s.cluster_name
|
||||||
|
grouped.setdefault(cluster, []).append(
|
||||||
|
{
|
||||||
|
"server_name": s.server_name,
|
||||||
|
"api_url": s.api_url,
|
||||||
|
"subscription_url": s.subscription_url,
|
||||||
|
"inbound_id": s.inbound_id,
|
||||||
|
"panel_type": s.panel_type,
|
||||||
|
"enabled": s.enabled,
|
||||||
|
"max_keys": s.max_keys,
|
||||||
|
"tariff_group": s.tariff_group,
|
||||||
|
"cluster_name": cluster,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Ошибка при получении серверов: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_clusters(session: AsyncSession) -> list[str]:
|
||||||
|
stmt = select(Server.cluster_name).distinct().order_by(Server.cluster_name)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return [r[0] for r in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def check_unique_server_name(
|
||||||
|
session: AsyncSession, server_name: str, cluster_name: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
stmt = select(Server).where(Server.server_name == server_name)
|
||||||
|
if cluster_name:
|
||||||
|
stmt = stmt.where(Server.cluster_name == cluster_name)
|
||||||
|
result = await session.execute(stmt.limit(1))
|
||||||
|
return result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_server_name_by_cluster(
|
||||||
|
session: AsyncSession, server_name: str
|
||||||
|
) -> dict | None:
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Server.cluster_name).where(Server.server_name == server_name)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return {"cluster_name": row[0]} if row else None
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Ошибка при поиске кластера для сервера {server_name}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_cluster_name_by_server(
|
||||||
|
session: AsyncSession, server_id_or_name: str
|
||||||
|
) -> str | None:
|
||||||
|
stmt = (
|
||||||
|
select(Server.cluster_name)
|
||||||
|
.where(
|
||||||
|
(Server.id == server_id_or_name) | (Server.server_name == server_id_or_name)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
return row
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, not_, select, exists
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Key, Payment, Referral, Tariff, User
|
||||||
|
|
||||||
|
|
||||||
|
async def count_total_users(session: AsyncSession) -> int:
|
||||||
|
return await session.scalar(select(func.count()).select_from(User))
|
||||||
|
|
||||||
|
|
||||||
|
async def count_users_updated_today(session: AsyncSession, today: date) -> int:
|
||||||
|
return await session.scalar(
|
||||||
|
select(func.count()).select_from(User).where(User.updated_at >= today)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def count_users_registered_since(session: AsyncSession, since: date) -> int:
|
||||||
|
return await session.scalar(
|
||||||
|
select(func.count()).select_from(User).where(User.created_at >= since)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def count_users_registered_between(
|
||||||
|
session: AsyncSession, start: date, end: date
|
||||||
|
) -> int:
|
||||||
|
return await session.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(User)
|
||||||
|
.where(User.created_at >= start, User.created_at < end)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def count_total_keys(session: AsyncSession) -> int:
|
||||||
|
return await session.scalar(select(func.count()).select_from(Key))
|
||||||
|
|
||||||
|
|
||||||
|
async def count_active_keys(session: AsyncSession) -> int:
|
||||||
|
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||||
|
return await session.scalar(
|
||||||
|
select(func.count()).select_from(Key).where(Key.expiry_time > current_time_ms)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def count_trial_keys(session: AsyncSession) -> int:
|
||||||
|
subquery_success_payments = (
|
||||||
|
select(Payment.tg_id)
|
||||||
|
.where(and_(Payment.tg_id == Key.tg_id, Payment.status == "success"))
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
return await session.scalar(
|
||||||
|
select(func.count()).select_from(Key).where(not_(subquery_success_payments))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tariff_distribution(
|
||||||
|
session: AsyncSession, include_unbound: bool = False
|
||||||
|
) -> tuple[list[tuple[int, int]], list[dict]]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Key.tariff_id, func.count(Key.client_id))
|
||||||
|
.where(Key.tariff_id.isnot(None))
|
||||||
|
.group_by(Key.tariff_id)
|
||||||
|
)
|
||||||
|
tariff_counts = result.all()
|
||||||
|
|
||||||
|
if not include_unbound:
|
||||||
|
return tariff_counts
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Key.expiry_time)
|
||||||
|
.where(Key.tariff_id.is_(None))
|
||||||
|
)
|
||||||
|
no_tariff_keys = [{"expiry_time": row[0]} for row in result.all()]
|
||||||
|
|
||||||
|
return tariff_counts, no_tariff_keys
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tariff_names(
|
||||||
|
session: AsyncSession, tariff_ids: list[int]
|
||||||
|
) -> dict[int, str]:
|
||||||
|
if not tariff_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff.id, Tariff.name).where(Tariff.id.in_(tariff_ids))
|
||||||
|
)
|
||||||
|
return dict(result.all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tariff_groups(
|
||||||
|
session: AsyncSession, tariff_ids: list[int]
|
||||||
|
) -> dict[int, str]:
|
||||||
|
if not tariff_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff.id, Tariff.group_code).where(Tariff.id.in_(tariff_ids))
|
||||||
|
)
|
||||||
|
return dict(result.all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tariff_durations(
|
||||||
|
session: AsyncSession, tariff_ids: list[int]
|
||||||
|
) -> dict[int, int]:
|
||||||
|
if not tariff_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff.id, Tariff.duration_days).where(Tariff.id.in_(tariff_ids))
|
||||||
|
)
|
||||||
|
return dict(result.all())
|
||||||
|
|
||||||
|
|
||||||
|
async def count_total_referrals(session: AsyncSession) -> int:
|
||||||
|
return await session.scalar(select(func.count()).select_from(Referral))
|
||||||
|
|
||||||
|
|
||||||
|
async def sum_payments_since(session: AsyncSession, since: date) -> float:
|
||||||
|
result = await session.scalar(
|
||||||
|
select(func.coalesce(func.sum(Payment.amount), 0)).where(
|
||||||
|
and_(
|
||||||
|
Payment.created_at >= since,
|
||||||
|
Payment.payment_system.notin_(["referral", "coupon"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return round(float(result), 2)
|
||||||
|
|
||||||
|
|
||||||
|
async def sum_payments_between(session: AsyncSession, start: date, end: date) -> float:
|
||||||
|
result = await session.scalar(
|
||||||
|
select(func.coalesce(func.sum(Payment.amount), 0)).where(
|
||||||
|
and_(
|
||||||
|
Payment.created_at >= start,
|
||||||
|
Payment.created_at < end,
|
||||||
|
Payment.payment_system.notin_(["referral", "coupon"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return round(float(result), 2)
|
||||||
|
|
||||||
|
|
||||||
|
async def sum_total_payments(session: AsyncSession) -> float:
|
||||||
|
result = await session.scalar(
|
||||||
|
select(func.coalesce(func.sum(Payment.amount), 0)).where(
|
||||||
|
Payment.payment_system.notin_(["referral", "coupon"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return round(float(result), 2)
|
||||||
|
|
||||||
|
|
||||||
|
async def count_hot_leads(session: AsyncSession) -> int:
|
||||||
|
subquery_active_keys = (
|
||||||
|
select(Key.tg_id)
|
||||||
|
.where(Key.expiry_time > int(datetime.utcnow().timestamp() * 1000))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Payment.tg_id)
|
||||||
|
.where(Payment.status == "success")
|
||||||
|
.where(not_(exists(subquery_active_keys.where(Key.tg_id == Payment.tg_id))))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(select(func.count()).select_from(stmt.subquery()))
|
||||||
|
return result.scalar()
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import delete, insert, select, update
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Server, Tariff
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tariffs(
|
||||||
|
session: AsyncSession, tariff_id: int = None, group_code: str = None
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
if tariff_id:
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
elif group_code:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff).where(Tariff.group_code == group_code)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = await session.execute(select(Tariff))
|
||||||
|
|
||||||
|
return [dict(r.__dict__) for r in result.scalars().all()]
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[TARIFF] Ошибка при получении тарифов: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tariff_by_id(session: AsyncSession, tariff_id: int):
|
||||||
|
try:
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
return dict(tariff.__dict__) if tariff else None
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[TARIFF] Ошибка при получении тарифа по ID {tariff_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tariffs_for_cluster(session: AsyncSession, cluster_name: str):
|
||||||
|
try:
|
||||||
|
server_row = await session.execute(
|
||||||
|
select(Server.tariff_group)
|
||||||
|
.where(Server.cluster_name == cluster_name)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
row = server_row.first()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
server_row = await session.execute(
|
||||||
|
select(Server.tariff_group)
|
||||||
|
.where(Server.server_name == cluster_name)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
row = server_row.first()
|
||||||
|
|
||||||
|
if not row or not row[0]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
group_code = row[0]
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff)
|
||||||
|
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
|
||||||
|
.order_by(Tariff.id)
|
||||||
|
)
|
||||||
|
return [dict(r.__dict__) for r in result.scalars().all()]
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[TARIFF] Ошибка при получении тарифов для кластера {cluster_name}: {e}"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def create_tariff(session: AsyncSession, data: dict):
|
||||||
|
try:
|
||||||
|
data["created_at"] = datetime.utcnow()
|
||||||
|
data["updated_at"] = datetime.utcnow()
|
||||||
|
|
||||||
|
stmt = insert(Tariff).values(**data).returning(Tariff)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return result.scalar_one()
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[TARIFF] Ошибка при создании тарифа: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def update_tariff(session: AsyncSession, tariff_id: int, updates: dict):
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
updates["updated_at"] = datetime.utcnow()
|
||||||
|
await session.execute(
|
||||||
|
update(Tariff).where(Tariff.id == tariff_id).values(**updates)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[TARIFF] Ошибка при обновлении тарифа ID={tariff_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_tariff(session: AsyncSession, tariff_id: int):
|
||||||
|
try:
|
||||||
|
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[TARIFF] Ошибка при удалении тарифа ID={tariff_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def check_tariff_exists(session: AsyncSession, tariff_id: int):
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff)
|
||||||
|
.where(Tariff.id == tariff_id, Tariff.is_active.is_(True))
|
||||||
|
)
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
if tariff:
|
||||||
|
logger.info(f"[TARIFF] Тариф {tariff_id} найден в БД: {tariff.group_code}")
|
||||||
|
return True
|
||||||
|
logger.warning(f"[TARIFF] Тариф {tariff_id} не найден в БД")
|
||||||
|
return False
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[TARIFF] Ошибка при проверке тарифа {tariff_id}: {e}")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import TemporaryData
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def create_temporary_data(
|
||||||
|
session: AsyncSession, tg_id: int, state: str, data: dict
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
insert(TemporaryData)
|
||||||
|
.values(tg_id=tg_id, state=state, data=data, updated_at=datetime.utcnow())
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[TemporaryData.tg_id],
|
||||||
|
set_={"state": state, "data": data, "updated_at": datetime.utcnow()},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"📝 Временные данные сохранены для {tg_id}")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при сохранении временных данных для {tg_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_temporary_data(session: AsyncSession, tg_id: int) -> dict | None:
|
||||||
|
stmt = select(TemporaryData).where(TemporaryData.tg_id == tg_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
if row:
|
||||||
|
return {"state": row.state, "data": row.data}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_temporary_data(session: AsyncSession, tg_id: int):
|
||||||
|
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"🗑 Временные данные очищены для {tg_id}")
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from sqlalchemy import func, insert, select, not_
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Payment, TrackingSource, User
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def create_tracking_source(
|
||||||
|
session: AsyncSession, name: str, code: str, type_: str, created_by: int
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
stmt = insert(TrackingSource).values(
|
||||||
|
name=name,
|
||||||
|
code=code,
|
||||||
|
type=type_,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"🆕 Источник трафика {code} создан")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"❌ Ошибка при создании источника {code}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
|
||||||
|
registrations_subq = (
|
||||||
|
select(func.count(func.distinct(User.tg_id)))
|
||||||
|
.where(User.source_code == TrackingSource.code)
|
||||||
|
.correlate(TrackingSource)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
trials_subq = (
|
||||||
|
select(func.count(func.distinct(User.tg_id)))
|
||||||
|
.where((User.source_code == TrackingSource.code) & (User.trial == 1))
|
||||||
|
.correlate(TrackingSource)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
payments_subq = (
|
||||||
|
select(func.count(func.distinct(Payment.tg_id)))
|
||||||
|
.join(User, Payment.tg_id == User.tg_id)
|
||||||
|
.where(
|
||||||
|
(User.source_code == TrackingSource.code) & (Payment.status == "success")
|
||||||
|
)
|
||||||
|
.correlate(TrackingSource)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
query = select(
|
||||||
|
TrackingSource.code,
|
||||||
|
TrackingSource.name,
|
||||||
|
TrackingSource.created_at,
|
||||||
|
registrations_subq.label("registrations"),
|
||||||
|
trials_subq.label("trials"),
|
||||||
|
payments_subq.label("payments"),
|
||||||
|
).order_by(TrackingSource.created_at.desc())
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
rows = result.all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"code": r.code,
|
||||||
|
"name": r.name,
|
||||||
|
"created_at": r.created_at,
|
||||||
|
"registrations": r.registrations or 0,
|
||||||
|
"trials": r.trials or 0,
|
||||||
|
"payments": r.payments or 0,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict | None:
|
||||||
|
reg_subq = (
|
||||||
|
select(func.count(func.distinct(User.tg_id)))
|
||||||
|
.where(User.source_code == code)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
trial_subq = (
|
||||||
|
select(func.count(func.distinct(User.tg_id)))
|
||||||
|
.where((User.source_code == code) & (User.trial == 1))
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
payments_subq = (
|
||||||
|
select(func.count(func.distinct(Payment.tg_id)))
|
||||||
|
.join(User, Payment.tg_id == User.tg_id)
|
||||||
|
.where(
|
||||||
|
(User.source_code == code)
|
||||||
|
& (Payment.status == "success")
|
||||||
|
& not_(Payment.payment_system.in_(["coupon", "referral"]))
|
||||||
|
)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
amount_subq = (
|
||||||
|
select(func.coalesce(func.sum(Payment.amount), 0))
|
||||||
|
.join(User, Payment.tg_id == User.tg_id)
|
||||||
|
.where(
|
||||||
|
(User.source_code == code)
|
||||||
|
& (Payment.status == "success")
|
||||||
|
& not_(Payment.payment_system.in_(["coupon", "referral"]))
|
||||||
|
)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
query = select(
|
||||||
|
TrackingSource.name,
|
||||||
|
TrackingSource.code,
|
||||||
|
TrackingSource.created_at,
|
||||||
|
reg_subq.label("registrations"),
|
||||||
|
trial_subq.label("trials"),
|
||||||
|
payments_subq.label("payments"),
|
||||||
|
amount_subq.label("total_amount"),
|
||||||
|
).where(TrackingSource.code == code)
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
row = result.first()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": row.name,
|
||||||
|
"code": row.code,
|
||||||
|
"created_at": row.created_at,
|
||||||
|
"registrations": row.registrations or 0,
|
||||||
|
"trials": row.trials or 0,
|
||||||
|
"payments": row.payments or 0,
|
||||||
|
"total_amount": float(row.total_amount or 0),
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import delete, exists, or_, select, update
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.keys import delete_key
|
||||||
|
from database.models import (
|
||||||
|
BlockedUser,
|
||||||
|
CouponUsage,
|
||||||
|
Gift,
|
||||||
|
Notification,
|
||||||
|
Payment,
|
||||||
|
Referral,
|
||||||
|
TemporaryData,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
async def add_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
tg_id: int,
|
||||||
|
username: str = None,
|
||||||
|
first_name: str = None,
|
||||||
|
last_name: str = None,
|
||||||
|
language_code: str = None,
|
||||||
|
is_bot: bool = False,
|
||||||
|
source_code: str = None,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
insert(User)
|
||||||
|
.values(
|
||||||
|
tg_id=tg_id,
|
||||||
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
language_code=language_code,
|
||||||
|
is_bot=is_bot,
|
||||||
|
source_code=source_code,
|
||||||
|
)
|
||||||
|
.on_conflict_do_nothing(index_elements=[User.tg_id])
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})"
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[DB] Ошибка при добавлении пользователя {tg_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> None:
|
||||||
|
try:
|
||||||
|
result = await session.execute(select(User.balance).where(User.tg_id == tg_id))
|
||||||
|
current = result.scalar_one_or_none() or 0
|
||||||
|
new_balance = current + amount
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.tg_id == tg_id).values(balance=new_balance)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"[DB] Баланс пользователя {tg_id} обновлён: {current} → {new_balance}"
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[DB] Ошибка при обновлении баланса пользователя {tg_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_user_exists(session: AsyncSession, tg_id: int) -> bool:
|
||||||
|
stmt = select(exists().where(User.tg_id == tg_id))
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_balance(session: AsyncSession, tg_id: int) -> float:
|
||||||
|
result = await session.execute(select(User.balance).where(User.tg_id == tg_id))
|
||||||
|
balance = result.scalar_one_or_none()
|
||||||
|
return round(balance, 1) if balance is not None else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) -> None:
|
||||||
|
try:
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.tg_id == tg_id).values(balance=balance)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_trial(session: AsyncSession, tg_id: int, status: int):
|
||||||
|
try:
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.tg_id == tg_id).values(trial=status)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"[DB] Триал статус обновлён для пользователя {tg_id}: {status}")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[DB] Ошибка при обновлении триала пользователя {tg_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_trial(session: AsyncSession, tg_id: int) -> int:
|
||||||
|
result = await session.execute(select(User.trial).where(User.tg_id == tg_id))
|
||||||
|
trial = result.scalar_one_or_none()
|
||||||
|
return trial or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
tg_id: int,
|
||||||
|
username: str = None,
|
||||||
|
first_name: str = None,
|
||||||
|
last_name: str = None,
|
||||||
|
language_code: str = None,
|
||||||
|
is_bot: bool = False,
|
||||||
|
only_if_exists: bool = False,
|
||||||
|
) -> dict | None:
|
||||||
|
try:
|
||||||
|
if only_if_exists:
|
||||||
|
result = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.tg_id == tg_id)
|
||||||
|
.values(
|
||||||
|
username=username or user.username,
|
||||||
|
first_name=first_name or user.first_name,
|
||||||
|
last_name=last_name or user.last_name,
|
||||||
|
language_code=language_code or user.language_code,
|
||||||
|
is_bot=is_bot,
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await session.execute(
|
||||||
|
insert(User)
|
||||||
|
.values(
|
||||||
|
tg_id=tg_id,
|
||||||
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
language_code=language_code,
|
||||||
|
is_bot=is_bot,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[User.tg_id],
|
||||||
|
set_={
|
||||||
|
"username": username,
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"language_code": language_code,
|
||||||
|
"is_bot": is_bot,
|
||||||
|
"updated_at": datetime.utcnow(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
result = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||||
|
return dict(result.scalar_one().__dict__)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_user_data(session: AsyncSession, tg_id: int):
|
||||||
|
try:
|
||||||
|
await session.execute(delete(Notification).where(Notification.tg_id == tg_id))
|
||||||
|
await session.execute(delete(Gift).where(Gift.sender_tg_id == tg_id))
|
||||||
|
await session.execute(
|
||||||
|
update(Gift)
|
||||||
|
.where(Gift.recipient_tg_id == tg_id)
|
||||||
|
.values(recipient_tg_id=None)
|
||||||
|
)
|
||||||
|
await session.execute(delete(Payment).where(Payment.tg_id == tg_id))
|
||||||
|
await session.execute(
|
||||||
|
delete(Referral).where(
|
||||||
|
or_(Referral.referrer_tg_id == tg_id, Referral.referred_tg_id == tg_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(delete(CouponUsage).where(CouponUsage.user_id == tg_id))
|
||||||
|
await delete_key(session, tg_id)
|
||||||
|
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
|
||||||
|
await session.execute(delete(BlockedUser).where(BlockedUser.tg_id == tg_id))
|
||||||
|
await session.execute(delete(User).where(User.tg_id == tg_id))
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"[DB] Данные пользователя {tg_id} полностью удалены")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_trial_extended(tg_id: int, session: AsyncSession):
|
||||||
|
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=-1))
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from aiogram.filters import BaseFilter
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from config import ADMIN_ID
|
||||||
|
|
||||||
|
|
||||||
|
class IsAdminFilter(BaseFilter):
|
||||||
|
async def __call__(self, message: Message) -> bool:
|
||||||
|
try:
|
||||||
|
return message.from_user.id in ADMIN_ID
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from aiogram.enums import ChatType
|
||||||
|
from aiogram.filters import BaseFilter
|
||||||
|
from aiogram.types import Chat, TelegramObject
|
||||||
|
|
||||||
|
|
||||||
|
class IsPrivateFilter(BaseFilter):
|
||||||
|
async def __call__(self, event: TelegramObject, event_chat: Chat) -> bool:
|
||||||
|
return event_chat.type == ChatType.PRIVATE
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
from .admin import router as admin_router
|
||||||
|
from .captcha import router as captcha_router
|
||||||
|
from .coupons import router as coupons_router
|
||||||
|
from .donate import router as donate_router
|
||||||
|
from .instructions import router as instructions_router
|
||||||
|
from .keys import router as keys_router
|
||||||
|
from .notifications import router as notifications_router
|
||||||
|
from .pay import router as pay_router
|
||||||
|
from .payments import router as payments_router
|
||||||
|
from .profile import router as profile_router
|
||||||
|
from .refferal import router as refferal_router
|
||||||
|
from .start import router as start_router
|
||||||
|
|
||||||
|
router = Router(name="handlers_main_router")
|
||||||
|
|
||||||
|
router.include_routers(
|
||||||
|
start_router,
|
||||||
|
captcha_router,
|
||||||
|
profile_router,
|
||||||
|
pay_router,
|
||||||
|
donate_router,
|
||||||
|
coupons_router,
|
||||||
|
notifications_router,
|
||||||
|
payments_router,
|
||||||
|
keys_router,
|
||||||
|
instructions_router,
|
||||||
|
admin_router,
|
||||||
|
refferal_router,
|
||||||
|
)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,35 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
from .ads import router as ads_router
|
||||||
|
from .backups import router as backups_router
|
||||||
|
from .bans import router as bans_router
|
||||||
|
from .clusters import router as clusters_router
|
||||||
|
from .coupons import router as coupons_router
|
||||||
|
from .management import router as management_router
|
||||||
|
from .panel import router as panel_router
|
||||||
|
from .restart import router as restart_router
|
||||||
|
from .sender import router as sender_router
|
||||||
|
from .servers import router as servers_router
|
||||||
|
from .stats import router as stats_router
|
||||||
|
from .tariffs import router as tariffs_router
|
||||||
|
from .users import router as users_router
|
||||||
|
|
||||||
|
router = Router(name="admins_main_router")
|
||||||
|
|
||||||
|
router.include_routers(
|
||||||
|
panel_router,
|
||||||
|
management_router,
|
||||||
|
servers_router,
|
||||||
|
clusters_router,
|
||||||
|
users_router,
|
||||||
|
stats_router,
|
||||||
|
backups_router,
|
||||||
|
sender_router,
|
||||||
|
coupons_router,
|
||||||
|
restart_router,
|
||||||
|
bans_router,
|
||||||
|
ads_router,
|
||||||
|
tariffs_router,
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .ads_handler import router
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy import delete, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import USERNAME_BOT
|
||||||
|
from database import create_tracking_source, get_tracking_source_stats
|
||||||
|
from database.models import TrackingSource, User
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
from .keyboard import (
|
||||||
|
AdminAdsCallback,
|
||||||
|
build_ads_delete_confirm_kb,
|
||||||
|
build_ads_kb,
|
||||||
|
build_ads_list_kb,
|
||||||
|
build_ads_stats_kb,
|
||||||
|
build_cancel_input_kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAdsState(StatesGroup):
|
||||||
|
waiting_for_new_name = State()
|
||||||
|
waiting_for_new_code = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
|
||||||
|
async def handle_ads_menu(callback_query: CallbackQuery):
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
|
||||||
|
async def handle_ads_create(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
await state.set_state(AdminAdsState.waiting_for_new_name)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"📝 Введите <b>название</b> новой ссылки:", reply_markup=build_cancel_input_kb()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminAdsState.waiting_for_new_name, IsAdminFilter())
|
||||||
|
async def handle_ads_name_input(message: Message, state: FSMContext):
|
||||||
|
name = message.text.strip()
|
||||||
|
await state.update_data(name=name)
|
||||||
|
await state.set_state(AdminAdsState.waiting_for_new_code)
|
||||||
|
await message.answer(
|
||||||
|
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.",
|
||||||
|
reply_markup=build_cancel_input_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
|
||||||
|
async def handle_ads_code_input(
|
||||||
|
message: Message, state: FSMContext, session: AsyncSession
|
||||||
|
):
|
||||||
|
code = message.text.strip()
|
||||||
|
data = await state.get_data()
|
||||||
|
name = data["name"]
|
||||||
|
code_with_prefix = f"utm_{code}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
await create_tracking_source(
|
||||||
|
name=name,
|
||||||
|
code=code_with_prefix,
|
||||||
|
type_="utm",
|
||||||
|
created_by=message.from_user.id,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
stats = await get_tracking_source_stats(session, code_with_prefix)
|
||||||
|
if not stats:
|
||||||
|
await message.answer("❌ Источник не найден или не содержит данных.")
|
||||||
|
return
|
||||||
|
msg = format_ads_stats(stats, USERNAME_BOT)
|
||||||
|
await message.answer(
|
||||||
|
text=msg,
|
||||||
|
reply_markup=build_ads_stats_kb(code_with_prefix),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании ссылки: {e}", exc_info=True)
|
||||||
|
await message.answer("❌ Произошла ошибка при создании ссылки.")
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminAdsCallback.filter(F.action == "list"), IsAdminFilter())
|
||||||
|
async def handle_ads_list(callback_query: CallbackQuery, session: AsyncSession):
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(TrackingSource).order_by(TrackingSource.created_at.desc())
|
||||||
|
)
|
||||||
|
ads = result.scalars().all()
|
||||||
|
reply_markup = build_ads_list_kb(ads, current_page=1, total_pages=1)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении списка UTM: {e}", exc_info=True)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"❌ Произошла ошибка при получении списка."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminAdsCallback.filter(F.action == "view"), IsAdminFilter())
|
||||||
|
async def handle_ads_view(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminAdsCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
code = callback_data.code
|
||||||
|
try:
|
||||||
|
stats = await get_tracking_source_stats(session, code)
|
||||||
|
if not stats:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"❌ Источник не найден или не содержит данных."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
msg = format_ads_stats(stats, USERNAME_BOT)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=msg, reply_markup=build_ads_stats_kb(code)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при просмотре статистики: {e}", exc_info=True)
|
||||||
|
await callback_query.message.edit_text("❌ Ошибка при получении статистики.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_ads_delete_confirm(
|
||||||
|
callback_query: CallbackQuery, callback_data: AdminAdsCallback
|
||||||
|
):
|
||||||
|
code = callback_data.code
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"Вы уверены, что хотите удалить ссылку <code>{code}</code>?",
|
||||||
|
reply_markup=build_ads_delete_confirm_kb(code),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminAdsCallback.filter(F.action == "delete"), IsAdminFilter())
|
||||||
|
async def handle_ads_delete(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminAdsCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
code = callback_data.code
|
||||||
|
try:
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.source_code == code).values(source_code=None)
|
||||||
|
)
|
||||||
|
await session.execute(delete(TrackingSource).where(TrackingSource.code == code))
|
||||||
|
await session.commit()
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
f"🗑️ Ссылка <code>{code}</code> удалена.",
|
||||||
|
reply_markup=build_ads_kb(),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении метки {code}: {e}", exc_info=True)
|
||||||
|
await callback_query.message.edit_text("❌ Не удалось удалить ссылку.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_ads_stats(stats: dict, username_bot: str) -> str:
|
||||||
|
return (
|
||||||
|
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
|
||||||
|
f"📌 <b>Название:</b> {stats['name']}\n"
|
||||||
|
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start={stats['code']}</code>\n"
|
||||||
|
f"🕓 <b>Создана:</b> {stats['created_at'].strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||||
|
f"💡 <b>Активность:</b>\n"
|
||||||
|
f"└ 🆕 <b>Регистраций:</b> <b>{stats.get('registrations', 0)}</b>\n"
|
||||||
|
f"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n\n"
|
||||||
|
f"💰 <b>Финансовая информация:</b>\n"
|
||||||
|
f"├ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n"
|
||||||
|
f"└ 💸 <b>Сумма:</b> <b>{round(stats.get('total_amount', 0), 2)} ₽</b>\n\n"
|
||||||
|
f"<i>Просмотр статистики и управление рекламными ссылками</i>."
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_btn
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAdsCallback(CallbackData, prefix="admin_ads"):
|
||||||
|
action: str
|
||||||
|
code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_ads_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="➕ Новая ссылка", callback_data=AdminAdsCallback(action="create").pack()
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="📊 Список", callback_data=AdminAdsCallback(action="list").pack()
|
||||||
|
)
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_ads_list_kb(
|
||||||
|
ads: list, current_page: int, total_pages: int
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
for ad in ads:
|
||||||
|
builder.button(
|
||||||
|
text=f"📎 {ad['name']}",
|
||||||
|
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
pagination_buttons = []
|
||||||
|
if current_page > 1:
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад",
|
||||||
|
callback_data=AdminAdsCallback(
|
||||||
|
action="list", code=f"{current_page - 1}"
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if current_page < total_pages:
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Вперед ➡️",
|
||||||
|
callback_data=AdminAdsCallback(
|
||||||
|
action="list", code=f"{current_page + 1}"
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pagination_buttons:
|
||||||
|
builder.row(*pagination_buttons)
|
||||||
|
|
||||||
|
builder.row(build_admin_back_btn("ads"))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="🗑️ Удалить",
|
||||||
|
callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack(),
|
||||||
|
)
|
||||||
|
builder.row(build_admin_back_btn("ads"))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_ads_delete_confirm_kb(code: str) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="✅ Да, удалить",
|
||||||
|
callback_data=AdminAdsCallback(
|
||||||
|
action="delete",
|
||||||
|
code=code,
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="❌ Отмена",
|
||||||
|
callback_data=AdminAdsCallback(action="view", code=code).pack(),
|
||||||
|
)
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_cancel_input_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="❌ Отмена",
|
||||||
|
callback_data=AdminAdsCallback(action="cancel_input", code="none").pack(),
|
||||||
|
)
|
||||||
|
return builder.as_markup()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .backups_handler import router
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
|
||||||
|
from backup import backup_database
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "backups"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_backups(callback_query: CallbackQuery):
|
||||||
|
kb = build_admin_back_kb("management")
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="💾 Инициализация резервного копирования базы данных...",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
exception = await backup_database()
|
||||||
|
|
||||||
|
if exception:
|
||||||
|
text = f"❌ Ошибка при создании резервной копии:\n<code>{exception}</code>"
|
||||||
|
else:
|
||||||
|
text = "✅ Резервная копия успешно создана и отправлена администраторам."
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
text = f"❌ Непредвиденная ошибка: <code>{e}</code>"
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(text=text, reply_markup=kb)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .bans_handler import router
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import BufferedInputFile, CallbackQuery
|
||||||
|
from sqlalchemy import delete, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import delete_user_data
|
||||||
|
from database.models import ManualBan
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||||
|
from .keyboard import build_bans_kb
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "bans"), IsAdminFilter())
|
||||||
|
async def handle_bans(callback_query: CallbackQuery):
|
||||||
|
text_ = (
|
||||||
|
"🚫 <b>Управление банами</b>\n\n"
|
||||||
|
"📛 <b>Забанившие бота</b> — пользователи, которые заблокировали бота вручную.\n"
|
||||||
|
"🔒 <b>Ручной бан</b> — пользователи, которых вы забанили через админку.\n\n"
|
||||||
|
"⬇ Выберите нужный раздел:"
|
||||||
|
)
|
||||||
|
await callback_query.message.edit_text(text=text_, reply_markup=build_bans_kb())
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSession):
|
||||||
|
kb = build_admin_back_kb("management")
|
||||||
|
try:
|
||||||
|
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
|
||||||
|
banned_users = result.all()
|
||||||
|
|
||||||
|
csv_output = io.StringIO()
|
||||||
|
writer = csv.writer(csv_output)
|
||||||
|
writer.writerow(["tg_id"])
|
||||||
|
|
||||||
|
for user in banned_users:
|
||||||
|
writer.writerow([user.tg_id])
|
||||||
|
|
||||||
|
csv_output.seek(0)
|
||||||
|
document = BufferedInputFile(
|
||||||
|
file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.answer_document(
|
||||||
|
document=document,
|
||||||
|
caption="📥 Экспорт пользователей, заблокировавших бота (CSV)",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await callback_query.message.answer(
|
||||||
|
text=f"❗ Произошла ошибка при экспорте: {e}",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "bans_delete_banned"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_bans_delete_banned(
|
||||||
|
callback_query: CallbackQuery, session: AsyncSession
|
||||||
|
):
|
||||||
|
kb = build_admin_back_kb("bans")
|
||||||
|
try:
|
||||||
|
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
|
||||||
|
blocked_users = result.all()
|
||||||
|
blocked_ids = [user.tg_id for user in blocked_users]
|
||||||
|
|
||||||
|
if not blocked_ids:
|
||||||
|
await callback_query.message.answer(
|
||||||
|
text="📂 Нет заблокировавших пользователей для удаления.",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for tg_id in blocked_ids:
|
||||||
|
await delete_user_data(session, tg_id)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
text("DELETE FROM blocked_users WHERE tg_id = ANY(:blocked_ids)"),
|
||||||
|
{"blocked_ids": blocked_ids},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await callback_query.message.answer(
|
||||||
|
text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await callback_query.message.answer(
|
||||||
|
text=f"❗ Произошла ошибка при удалении записей: {e}",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_manual_bans_export(
|
||||||
|
callback_query: CallbackQuery, session: AsyncSession
|
||||||
|
):
|
||||||
|
build_admin_back_kb("bans")
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
text("SELECT tg_id, banned_at, reason, until FROM manual_bans")
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
csv_output = io.StringIO()
|
||||||
|
writer = csv.writer(csv_output)
|
||||||
|
writer.writerow(["tg_id", "banned_at", "reason", "until"])
|
||||||
|
|
||||||
|
for user in rows:
|
||||||
|
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until])
|
||||||
|
|
||||||
|
csv_output.seek(0)
|
||||||
|
document = BufferedInputFile(
|
||||||
|
file=csv_output.getvalue().encode("utf-8"), filename="manual_bans.csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.answer_document(
|
||||||
|
document=document,
|
||||||
|
caption="📥 Экспорт вручную забаненных пользователей",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await callback_query.message.answer(
|
||||||
|
text=f"❗ Ошибка при экспорте: {e}",
|
||||||
|
reply_markup=build_admin_back_kb("bans"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "bans_delete_manual"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_delete_manual_banned(
|
||||||
|
callback_query: CallbackQuery, session: AsyncSession
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await session.execute(delete(ManualBan))
|
||||||
|
await session.commit()
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"🗑️ Вручную забаненные пользователи удалены.",
|
||||||
|
reply_markup=build_bans_kb(),
|
||||||
|
)
|
||||||
|
logger.info("[BANS] Очищены записи из manual_bans")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[BANS] Ошибка при очистке manual_bans: {e}")
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"❌ Ошибка при удалении вручную забаненных пользователей."
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||||
|
|
||||||
|
|
||||||
|
def build_bans_kb():
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text="📛 Забанившие бота",
|
||||||
|
callback_data=AdminPanelCallback(action="bans_export").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="📛 Забаненные вручную",
|
||||||
|
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🗑️ Удалить забанивших",
|
||||||
|
callback_data=AdminPanelCallback(action="bans_delete_banned").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🗑️ Очистить вручную забаненных",
|
||||||
|
callback_data=AdminPanelCallback(action="bans_delete_manual").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🔙 Назад", callback_data=AdminPanelCallback(action="management").pack()
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .clusters_handler import router
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
|||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||||
|
from ..servers.keyboard import AdminServerCallback
|
||||||
|
|
||||||
|
|
||||||
|
class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
|
||||||
|
action: str
|
||||||
|
data: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
cluster_names = list(servers.keys())
|
||||||
|
for i in range(0, len(cluster_names), 2):
|
||||||
|
row_buttons = []
|
||||||
|
for name in cluster_names[i : i + 2]:
|
||||||
|
servers_in_cluster = servers[name]
|
||||||
|
all_disabled = all(not s["enabled"] for s in servers_in_cluster)
|
||||||
|
label = f"❌ {name} (отключен)" if all_disabled else f"⚙️ {name}"
|
||||||
|
row_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=label,
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="manage", data=name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(*row_buttons)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="➕ Добавить кластер",
|
||||||
|
callback_data=AdminClusterCallback(action="add").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_manage_cluster_kb(
|
||||||
|
cluster_servers: list, cluster_name: str
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
for server in cluster_servers:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"🌍 {server['server_name']}",
|
||||||
|
callback_data=AdminServerCallback(
|
||||||
|
action="manage", data=server["server_name"]
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="➕ Добавить сервер",
|
||||||
|
callback_data=AdminServerCallback(action="add", data=cluster_name).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔙 Назад",
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="manage", data=cluster_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📡 Серверы",
|
||||||
|
callback_data=f"cluster_servers|{cluster_name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🌐 Доступность",
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="availability", data=cluster_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔄 Синхронизация",
|
||||||
|
callback_data=AdminClusterCallback(action="sync", data=cluster_name).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="💾 Создать бэкап",
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="backup", data=cluster_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⏳ Добавить время",
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="add_time", data=cluster_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✏️ Сменить название",
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="rename", data=cluster_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="💸 Тариф(Установить/изменить)",
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="set_tariff", data=cluster_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_sync_cluster_kb(
|
||||||
|
cluster_servers: list, cluster_name: str
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
for server in cluster_servers:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"🔄 Синхронизировать {server['server_name']}",
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="sync-server", data=server["server_name"]
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📍 Синхронизировать кластер",
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="sync-cluster", data=cluster_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(build_admin_back_btn("clusters"))
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_panel_type_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack()
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🌀 Remnawave",
|
||||||
|
callback_data=AdminClusterCallback(action="panel_remnawave").pack(),
|
||||||
|
)
|
||||||
|
builder.row(build_admin_back_btn("clusters"))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_tariff_group_selection_kb(
|
||||||
|
cluster_name: str, groups: list[tuple[int, str]]
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for group_id, group_code in groups:
|
||||||
|
builder.button(
|
||||||
|
text=group_code,
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="apply_tariff_group", data=f"{cluster_name}|{group_id}"
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад",
|
||||||
|
callback_data=AdminClusterCallback(
|
||||||
|
action="manage", data=cluster_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.adjust(2, 1)
|
||||||
|
return builder.as_markup()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .coupons_handler import router
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
InlineQuery,
|
||||||
|
InlineQueryResultArticle,
|
||||||
|
InputTextMessageContent,
|
||||||
|
Message,
|
||||||
|
)
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from config import INLINE_MODE, USERNAME_BOT
|
||||||
|
from database import create_coupon, delete_coupon, get_all_coupons
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from handlers.buttons import BACK
|
||||||
|
from handlers.utils import format_days
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||||
|
from .keyboard import (
|
||||||
|
AdminCouponDeleteCallback,
|
||||||
|
build_coupons_kb,
|
||||||
|
build_coupons_list_kb,
|
||||||
|
format_coupons_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminCouponsState(StatesGroup):
|
||||||
|
waiting_for_coupon_type = State()
|
||||||
|
waiting_for_balance_data = State()
|
||||||
|
waiting_for_days_data = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "coupons"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_coupons(callback_query: CallbackQuery):
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "coupons_create"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
text = "🎫 <b>Выберите тип купона:</b>"
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
|
||||||
|
kb.button(text="⏳ Время", callback_data="coupon_type_days")
|
||||||
|
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||||
|
kb.adjust(1)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||||
|
await state.set_state(AdminCouponsState.waiting_for_coupon_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "coupon_type_balance")
|
||||||
|
async def handle_balance_coupon_selection(
|
||||||
|
callback_query: CallbackQuery, state: FSMContext
|
||||||
|
):
|
||||||
|
text = (
|
||||||
|
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||||
|
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||||
|
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
|
||||||
|
)
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||||
|
await state.set_state(AdminCouponsState.waiting_for_balance_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "coupon_type_days")
|
||||||
|
async def handle_days_coupon_selection(
|
||||||
|
callback_query: CallbackQuery, state: FSMContext
|
||||||
|
):
|
||||||
|
text = (
|
||||||
|
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||||
|
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
|
||||||
|
"Пример: <b>'DAYS10 10 50'</b> 👈\n\n"
|
||||||
|
)
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||||
|
await state.set_state(AdminCouponsState.waiting_for_days_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
|
||||||
|
async def handle_balance_coupon_input(
|
||||||
|
message: Message, state: FSMContext, session: Any
|
||||||
|
):
|
||||||
|
text = message.text.strip()
|
||||||
|
parts = text.split()
|
||||||
|
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||||
|
|
||||||
|
if len(parts) != 3:
|
||||||
|
text = (
|
||||||
|
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||||
|
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
|
||||||
|
"Пример: <b>'COUPON1 50 5'</b> 👈"
|
||||||
|
)
|
||||||
|
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
coupon_code = parts[0]
|
||||||
|
coupon_amount = int(parts[1])
|
||||||
|
usage_limit = int(parts[2])
|
||||||
|
if coupon_amount <= 0:
|
||||||
|
raise ValueError("Сумма должна быть больше 0")
|
||||||
|
except ValueError:
|
||||||
|
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
|
||||||
|
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await create_coupon(session, coupon_code, coupon_amount, usage_limit, days=None)
|
||||||
|
|
||||||
|
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||||
|
text = (
|
||||||
|
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||||
|
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
|
||||||
|
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||||
|
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
if INLINE_MODE:
|
||||||
|
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
|
||||||
|
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||||
|
kb.adjust(1)
|
||||||
|
|
||||||
|
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании купона: {e}")
|
||||||
|
await message.answer(
|
||||||
|
"❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminCouponsState.waiting_for_days_data, IsAdminFilter())
|
||||||
|
async def handle_days_coupon_input(message: Message, state: FSMContext, session: Any):
|
||||||
|
text = message.text.strip()
|
||||||
|
parts = text.split()
|
||||||
|
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||||
|
|
||||||
|
if len(parts) != 3:
|
||||||
|
text = (
|
||||||
|
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||||
|
"🏷️ <b>код</b> ⏳ <i>дни</i> 🔢 <b>лимит</b>\n"
|
||||||
|
"Пример: <b>'DAYS10 10 50'</b> 👈"
|
||||||
|
)
|
||||||
|
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
coupon_code = parts[0]
|
||||||
|
days = int(parts[1])
|
||||||
|
usage_limit = int(parts[2])
|
||||||
|
if days <= 0:
|
||||||
|
raise ValueError("Количество дней должно быть больше 0")
|
||||||
|
except ValueError:
|
||||||
|
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Дни должны быть числом, а лимит — целым числом."
|
||||||
|
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await create_coupon(session, coupon_code, 0, usage_limit, days=days)
|
||||||
|
|
||||||
|
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||||
|
text = (
|
||||||
|
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||||
|
f"⏳ <b>{format_days(days)}</b>\n"
|
||||||
|
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||||
|
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
if INLINE_MODE:
|
||||||
|
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
|
||||||
|
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||||
|
kb.adjust(1)
|
||||||
|
|
||||||
|
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании купона: {e}")
|
||||||
|
await message.answer(
|
||||||
|
"❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "coupons_list"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
|
||||||
|
try:
|
||||||
|
data = AdminPanelCallback.unpack(callback_query.data)
|
||||||
|
page = data.page if data.page is not None else 1
|
||||||
|
await update_coupons_list(callback_query.message, session, page)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении списка купонов: {e}")
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"Произошла ошибка при получении списка купонов."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_coupon_delete(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminCouponDeleteCallback,
|
||||||
|
session: Any,
|
||||||
|
):
|
||||||
|
coupon_code = callback_data.coupon_code
|
||||||
|
kb = InlineKeyboardBuilder()
|
||||||
|
kb.button(
|
||||||
|
text="✅ Да, удалить",
|
||||||
|
callback_data=AdminCouponDeleteCallback(
|
||||||
|
coupon_code=coupon_code, confirm=True
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
kb.button(
|
||||||
|
text="❌ Нет, отменить",
|
||||||
|
callback_data=AdminCouponDeleteCallback(
|
||||||
|
coupon_code=coupon_code, confirm=False
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
kb.adjust(1)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?",
|
||||||
|
reply_markup=kb.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def confirm_coupon_delete(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminCouponDeleteCallback,
|
||||||
|
session: Any,
|
||||||
|
):
|
||||||
|
coupon_code = callback_data.coupon_code
|
||||||
|
confirm = callback_data.confirm
|
||||||
|
|
||||||
|
if confirm:
|
||||||
|
try:
|
||||||
|
result = await delete_coupon(session, coupon_code)
|
||||||
|
if not result:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
f"❌ Купон с кодом {coupon_code} не найден.",
|
||||||
|
reply_markup=build_admin_back_kb("coupons"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении купона: {e}")
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"Произошла ошибка при удалении купона.",
|
||||||
|
reply_markup=build_admin_back_kb("coupons"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await update_coupons_list(callback_query.message, session)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_coupons_list(message, session: Any, page: int = 1):
|
||||||
|
per_page = 10
|
||||||
|
result = await get_all_coupons(session, page, per_page)
|
||||||
|
coupons = result["coupons"]
|
||||||
|
|
||||||
|
if not coupons:
|
||||||
|
await message.edit_text(
|
||||||
|
text="❌ На данный момент нет доступных купонов!",
|
||||||
|
reply_markup=build_admin_back_kb("coupons"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
|
||||||
|
text = format_coupons_list(coupons, USERNAME_BOT)
|
||||||
|
await message.edit_text(text=text, reply_markup=kb)
|
||||||
|
|
||||||
|
|
||||||
|
@router.inline_query(F.query.startswith("coupon_"))
|
||||||
|
async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
|
||||||
|
if not INLINE_MODE:
|
||||||
|
return
|
||||||
|
|
||||||
|
coupon_code = inline_query.query.split("coupon_")[1]
|
||||||
|
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||||
|
|
||||||
|
coupons = await get_all_coupons(session, page=1, per_page=10)
|
||||||
|
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
|
||||||
|
|
||||||
|
if not coupon:
|
||||||
|
await inline_query.answer(
|
||||||
|
results=[],
|
||||||
|
switch_pm_text="Купон не найден",
|
||||||
|
switch_pm_parameter="coupons",
|
||||||
|
cache_time=1,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
title = f"Купон {coupon['code']}"
|
||||||
|
description = (
|
||||||
|
f"Получи {coupon['amount']} рублей!"
|
||||||
|
if coupon["amount"] > 0
|
||||||
|
else f"Продли подписку на {format_days(coupon['days'])}!"
|
||||||
|
)
|
||||||
|
message_text = (
|
||||||
|
f"🎫 <b>Купон:</b> {coupon['code']}\n"
|
||||||
|
f"{'💰 <b>Бонус:</b> ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ <b>Продление:</b> ' + format_days(coupon['days'])}\n"
|
||||||
|
f"👇 Нажми, чтобы активировать!"
|
||||||
|
)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(text="Активировать купон", url=coupon_link)
|
||||||
|
|
||||||
|
result = InlineQueryResultArticle(
|
||||||
|
id=coupon_code,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
input_message_content=InputTextMessageContent(
|
||||||
|
message_text=message_text, parse_mode=ParseMode.HTML
|
||||||
|
),
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await inline_query.answer(results=[result], cache_time=86400, is_personal=True)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from handlers.buttons import BACK
|
||||||
|
from handlers.utils import format_days
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||||
|
|
||||||
|
|
||||||
|
class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
|
||||||
|
coupon_code: str
|
||||||
|
confirm: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_coupons_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="➕ Создать купон",
|
||||||
|
callback_data=AdminPanelCallback(action="coupons_create").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Купоны",
|
||||||
|
callback_data=AdminPanelCallback(action="coupons_list").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_coupons_list_kb(
|
||||||
|
coupons: list, current_page: int, total_pages: int
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
for coupon in coupons:
|
||||||
|
coupon_code = coupon["code"]
|
||||||
|
builder.button(
|
||||||
|
text=f"❌{coupon_code}",
|
||||||
|
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
pagination_buttons = []
|
||||||
|
if current_page > 1:
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=BACK,
|
||||||
|
callback_data=AdminPanelCallback(
|
||||||
|
action="coupons_list", page=current_page - 1
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if current_page < total_pages:
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Вперед ➡️",
|
||||||
|
callback_data=AdminPanelCallback(
|
||||||
|
action="coupons_list", page=current_page + 1
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pagination_buttons:
|
||||||
|
builder.row(*pagination_buttons)
|
||||||
|
|
||||||
|
builder.row(build_admin_back_btn("coupons"))
|
||||||
|
builder.adjust(2)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def format_coupons_list(coupons: list, username_bot: str) -> str:
|
||||||
|
coupon_list = "📜 Список всех купонов:\n\n"
|
||||||
|
for coupon in coupons:
|
||||||
|
value_text = (
|
||||||
|
f"💰 <b>Сумма:</b> {coupon['amount']} рублей"
|
||||||
|
if coupon["amount"] > 0
|
||||||
|
else f"⏳ <b>{format_days(coupon['days'])}</b>"
|
||||||
|
)
|
||||||
|
coupon_list += (
|
||||||
|
f"🏷️ <b>Код:</b> {coupon['code']}\n"
|
||||||
|
f"{value_text}\n"
|
||||||
|
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
|
||||||
|
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
|
||||||
|
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>\n\n"
|
||||||
|
)
|
||||||
|
return coupon_list
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .management_handler import router
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from middlewares import maintenance
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||||
|
|
||||||
|
|
||||||
|
def build_management_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="💾 Создать резервную копию",
|
||||||
|
callback_data=AdminPanelCallback(action="backups").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="📛 Управление банами",
|
||||||
|
callback_data=AdminPanelCallback(action="bans").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🔄 Перезагрузить бота",
|
||||||
|
callback_data=AdminPanelCallback(action="restart").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🌐 Сменить домен",
|
||||||
|
callback_data=AdminPanelCallback(action="change_domain").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🔑 Восстановить пробники",
|
||||||
|
callback_data=AdminPanelCallback(action="restore_trials").pack(),
|
||||||
|
)
|
||||||
|
maintenance_text = (
|
||||||
|
"🛠️ Выключить тех. работы"
|
||||||
|
if maintenance.maintenance_mode
|
||||||
|
else "🛠️ Включить тех. работы"
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text=maintenance_text,
|
||||||
|
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Key
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from logger import logger
|
||||||
|
from middlewares import maintenance
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_kb
|
||||||
|
from .keyboard import AdminPanelCallback, build_management_kb
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminManagementStates(StatesGroup):
|
||||||
|
waiting_for_new_domain = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "management"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_management(callback_query: CallbackQuery):
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="🤖 Управление ботом",
|
||||||
|
reply_markup=build_management_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
"""Запрашивает у администратора новый домен."""
|
||||||
|
await state.set_state(AdminManagementStates.waiting_for_new_domain)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminManagementStates.waiting_for_new_domain)
|
||||||
|
async def process_new_domain(
|
||||||
|
message: Message, state: FSMContext, session: AsyncSession
|
||||||
|
):
|
||||||
|
"""Обновляет домен в таблице keys."""
|
||||||
|
new_domain = message.text.strip()
|
||||||
|
logger.info(
|
||||||
|
f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
|
||||||
|
logger.warning("[DomainChange] Некорректный домен")
|
||||||
|
await message.answer(
|
||||||
|
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
|
||||||
|
reply_markup=build_admin_back_kb("admin"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_domain_url = f"https://{new_domain}"
|
||||||
|
logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
update(Key)
|
||||||
|
.where(~Key.key.startswith(new_domain_url))
|
||||||
|
.values(key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url))
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
|
||||||
|
await message.answer(
|
||||||
|
f"❌ Ошибка при обновлении домена: {e}",
|
||||||
|
reply_markup=build_admin_back_kb("admin"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
sample = await session.execute(select(Key.key).limit(1))
|
||||||
|
example = sample.scalar()
|
||||||
|
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Домен успешно изменен на {new_domain}!",
|
||||||
|
reply_markup=build_admin_back_kb("admin"),
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
|
||||||
|
async def toggle_maintenance_mode(callback: CallbackQuery):
|
||||||
|
maintenance.maintenance_mode = not maintenance.maintenance_mode
|
||||||
|
|
||||||
|
new_status = "включён" if maintenance.maintenance_mode else "выключен"
|
||||||
|
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
|
||||||
|
|
||||||
|
await callback.message.edit_reply_markup(reply_markup=build_management_kb())
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .panel_handler import router
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from handlers.buttons import BACK, MAIN_MENU
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPanelCallback(CallbackData, prefix="admin_panel"):
|
||||||
|
action: str
|
||||||
|
page: int
|
||||||
|
|
||||||
|
def __init__(self, /, **data: Any) -> None:
|
||||||
|
if "page" not in data or data["page"] is None:
|
||||||
|
data["page"] = 1
|
||||||
|
super().__init__(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def build_panel_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="👤 Поиск пользователя",
|
||||||
|
callback_data=AdminPanelCallback(action="search_user").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🔑 Поиск по названию ключа",
|
||||||
|
callback_data=AdminPanelCallback(action="search_key").pack(),
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack()
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🤖 Управление",
|
||||||
|
callback_data=AdminPanelCallback(action="management").pack(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📊 Статистика",
|
||||||
|
callback_data=AdminPanelCallback(action="stats").pack(),
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||||
|
builder.adjust(1, 1, 2, 2, 1, 2, 1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_restart_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="✅ Да, перезагрузить",
|
||||||
|
callback_data=AdminPanelCallback(action="restart_confirm").pack(),
|
||||||
|
)
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup:
|
||||||
|
return build_admin_singleton_kb(BACK, action)
|
||||||
|
|
||||||
|
|
||||||
|
def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(build_admin_btn(text, action))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
|
||||||
|
return build_admin_btn(BACK, action)
|
||||||
|
|
||||||
|
|
||||||
|
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
|
||||||
|
return InlineKeyboardButton(
|
||||||
|
text=text, callback_data=AdminPanelCallback(action=action).pack()
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
|
from bot import version
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from .keyboard import AdminPanelCallback, build_panel_kb
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
|
||||||
|
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
text = f"🤖 Панель администратора\n\n📌 Версия бота: {version}"
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
if callback_query.message.text:
|
||||||
|
try:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_panel_kb(),
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
if "message is not modified" in str(e):
|
||||||
|
logger.warning(
|
||||||
|
"🔄 Попытка редактировать сообщение без изменений — пропущено."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await callback_query.message.delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||||
|
|
||||||
|
await callback_query.message.answer(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_panel_kb(),
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "admin", IsAdminFilter())
|
||||||
|
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
await handle_admin_message(callback_query.message, state)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("admin"), IsAdminFilter())
|
||||||
|
async def handle_admin_message(message: Message, state: FSMContext):
|
||||||
|
text = f"🤖 Панель администратора\n\n📌 Версия бота: {version}"
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(text=text, reply_markup=build_panel_kb())
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .restart_handler import router
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "restart"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_restart_confirm(
|
||||||
|
callback_query: CallbackQuery, callback_data: AdminPanelCallback
|
||||||
|
):
|
||||||
|
kb = build_admin_back_kb()
|
||||||
|
await callback_query.message.edit_text("🔄 Перезапускаем бота...", reply_markup=kb)
|
||||||
|
|
||||||
|
asyncio.create_task(restart_bot())
|
||||||
|
|
||||||
|
|
||||||
|
async def restart_bot():
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parent = psutil.Process(os.getpid()).parent()
|
||||||
|
is_systemd = parent and "systemd" in parent.name().lower()
|
||||||
|
|
||||||
|
if is_systemd:
|
||||||
|
subprocess.run(
|
||||||
|
["sudo", "systemctl", "restart", "bot.service"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
python_exe = sys.executable
|
||||||
|
script_path = os.path.abspath(sys.argv[0])
|
||||||
|
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Restart] Ошибка при перезапуске: {e}")
|
||||||
|
os._exit(1)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .sender_handler import router
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_btn
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSenderCallback(CallbackData, prefix="admin_sender"):
|
||||||
|
type: str
|
||||||
|
data: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_sender_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="👥 Все пользователи",
|
||||||
|
callback_data=AdminSenderCallback(type="all").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✅ С подпиской",
|
||||||
|
callback_data=AdminSenderCallback(type="subscribed").pack(),
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Без подписки",
|
||||||
|
callback_data=AdminSenderCallback(type="unsubscribed").pack(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📍 Не использовавшие триал",
|
||||||
|
callback_data=AdminSenderCallback(type="untrial").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔥 Горячие лиды",
|
||||||
|
callback_data=AdminSenderCallback(type="hotleads").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📢 Кластер",
|
||||||
|
callback_data=AdminSenderCallback(type="cluster-select").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
for cluster in clusters:
|
||||||
|
name = cluster["cluster_name"]
|
||||||
|
builder.button(
|
||||||
|
text=f"🌐 {name}",
|
||||||
|
callback_data=AdminSenderCallback(type="cluster", data=name).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(2)
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy import distinct, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.models import Key, Payment, Server, User
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||||
|
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSender(StatesGroup):
|
||||||
|
waiting_for_message = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "sender"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_sender(callback_query: CallbackQuery):
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="✍️ Выберите группу пользователей для рассылки:",
|
||||||
|
reply_markup=build_sender_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminSenderCallback.filter(F.type != "cluster-select"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_sender_callback_text(
|
||||||
|
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
|
||||||
|
):
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text="✍️ Введите текст сообщения для рассылки:",
|
||||||
|
reply_markup=build_admin_back_kb("sender"),
|
||||||
|
)
|
||||||
|
await state.update_data(type=callback_data.type, cluster_name=callback_data.data)
|
||||||
|
await state.set_state(AdminSender.waiting_for_message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminSenderCallback.filter(F.type == "cluster-select"),
|
||||||
|
IsAdminFilter(),
|
||||||
|
)
|
||||||
|
async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSession):
|
||||||
|
result = await session.execute(select(Server.cluster_name).distinct())
|
||||||
|
clusters = result.mappings().all()
|
||||||
|
|
||||||
|
await callback_query.message.answer(
|
||||||
|
"✍️ Выберите кластер для рассылки сообщений:",
|
||||||
|
reply_markup=build_clusters_kb(clusters),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
|
||||||
|
async def handle_message_input(message: Message, state: FSMContext, session):
|
||||||
|
text_message = message.html_text if message.text else None
|
||||||
|
photo = message.photo[-1].file_id if message.photo else None
|
||||||
|
photo_url = (
|
||||||
|
message.caption
|
||||||
|
if message.photo and message.caption and message.caption.startswith("http")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not text_message and message.caption:
|
||||||
|
text_message = message.caption
|
||||||
|
|
||||||
|
if not text_message and not photo and not photo_url:
|
||||||
|
await message.answer("⚠ Ошибка! Отправьте текст или изображение для рассылки.")
|
||||||
|
return
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
send_to = state_data.get("type", "all")
|
||||||
|
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||||
|
|
||||||
|
query = None
|
||||||
|
|
||||||
|
if send_to == "subscribed":
|
||||||
|
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms)
|
||||||
|
|
||||||
|
elif send_to == "unsubscribed":
|
||||||
|
subquery = (
|
||||||
|
select(User.tg_id)
|
||||||
|
.outerjoin(Key, User.tg_id == Key.tg_id)
|
||||||
|
.group_by(User.tg_id)
|
||||||
|
.having(func.count(Key.tg_id) == 0)
|
||||||
|
.union_all(
|
||||||
|
select(User.tg_id)
|
||||||
|
.join(Key, User.tg_id == Key.tg_id)
|
||||||
|
.group_by(User.tg_id)
|
||||||
|
.having(func.max(Key.expiry_time) <= now_ms)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
query = select(distinct(subquery.c.tg_id))
|
||||||
|
|
||||||
|
elif send_to == "untrial":
|
||||||
|
subquery = select(Key.tg_id)
|
||||||
|
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery))
|
||||||
|
|
||||||
|
elif send_to == "cluster":
|
||||||
|
cluster_name = state_data.get("cluster_name")
|
||||||
|
query = (
|
||||||
|
select(distinct(User.tg_id))
|
||||||
|
.join(Key, User.tg_id == Key.tg_id)
|
||||||
|
.join(Server, Key.server_id == Server.cluster_name)
|
||||||
|
.where(Server.cluster_name == cluster_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif send_to == "hotleads":
|
||||||
|
subquery = select(Key.tg_id)
|
||||||
|
query = (
|
||||||
|
select(distinct(User.tg_id))
|
||||||
|
.join(Payment, User.tg_id == Payment.tg_id)
|
||||||
|
.where(Payment.status == "success")
|
||||||
|
.where(~User.tg_id.in_(subquery))
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
query = select(distinct(User.tg_id))
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
tg_ids = [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
total_users = len(tg_ids)
|
||||||
|
success_count = 0
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for tg_id in tg_ids:
|
||||||
|
try:
|
||||||
|
if photo or photo_url:
|
||||||
|
await message.bot.send_photo(
|
||||||
|
chat_id=tg_id,
|
||||||
|
photo=photo or photo_url,
|
||||||
|
caption=text_message,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.bot.send_message(
|
||||||
|
chat_id=tg_id, text=text_message, parse_mode="HTML"
|
||||||
|
)
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text=(
|
||||||
|
f"📤 <b>Рассылка завершена!</b>\n\n"
|
||||||
|
f"👥 <b>Количество получателей:</b> {total_users}\n"
|
||||||
|
f"✅ <b>Доставлено:</b> {success_count}\n"
|
||||||
|
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
|
||||||
|
),
|
||||||
|
reply_markup=build_admin_back_kb("sender"),
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .servers_handler import router
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from handlers.buttons import BACK
|
||||||
|
|
||||||
|
|
||||||
|
class AdminServerCallback(CallbackData, prefix="admin_server"):
|
||||||
|
action: str
|
||||||
|
data: str
|
||||||
|
|
||||||
|
|
||||||
|
def build_manage_server_kb(
|
||||||
|
server_name: str, cluster_name: str, enabled: bool
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
toggle_text = "🔴 Отключить" if enabled else "🟢 Включить"
|
||||||
|
toggle_action = "disable" if enabled else "enable"
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text=toggle_text,
|
||||||
|
callback_data=AdminServerCallback(
|
||||||
|
action=toggle_action, data=server_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text="📈 Задать лимит",
|
||||||
|
callback_data=AdminServerCallback(action="set_limit", data=server_name).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text="🗑️ Удалить",
|
||||||
|
callback_data=AdminServerCallback(action="delete", data=server_name).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text="✏️ Сменить название",
|
||||||
|
callback_data=AdminServerCallback(action="rename", data=server_name).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text="🔙 Назад",
|
||||||
|
callback_data=f"cluster_servers|{cluster_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
from aiogram import F, Router, types
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from sqlalchemy import delete, func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import get_servers
|
||||||
|
from database.models import Key, Server
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from handlers.buttons import BACK
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_kb
|
||||||
|
from .keyboard import AdminServerCallback, build_manage_server_kb
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class ServerLimitState(StatesGroup):
|
||||||
|
waiting_for_limit = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminServerCallback.filter(F.action == "manage"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_server_manage(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminServerCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
server_name = callback_data.data
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
|
||||||
|
cluster_name, server = next(
|
||||||
|
(
|
||||||
|
(c, s)
|
||||||
|
for c, cs in servers.items()
|
||||||
|
for s in cs
|
||||||
|
if s["server_name"] == server_name
|
||||||
|
),
|
||||||
|
(None, None),
|
||||||
|
)
|
||||||
|
|
||||||
|
if server:
|
||||||
|
api_url = server["api_url"]
|
||||||
|
subscription_url = server["subscription_url"]
|
||||||
|
inbound_id = server["inbound_id"]
|
||||||
|
max_keys = server.get("max_keys")
|
||||||
|
limit_display = f"{max_keys}" if max_keys else "не задан"
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||||
|
f"<b>📡 API URL:</b> {api_url}\n"
|
||||||
|
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
|
||||||
|
f"<b>🔑 Inbound ID:</b> {inbound_id}\n"
|
||||||
|
f"<b>📈 Лимит ключей:</b> {limit_display}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_manage_server_kb(
|
||||||
|
server_name, cluster_name, enabled=server.get("enabled", True)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await callback_query.message.edit_text(text="❌ Сервер не найден.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminServerCallback.filter(F.action == "delete"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def process_callback_delete_server(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminServerCallback,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
from ..clusters.clusters_handler import AdminClusterStates
|
||||||
|
|
||||||
|
server_name = callback_data.data
|
||||||
|
|
||||||
|
servers_dict = await get_servers(session)
|
||||||
|
cluster_name = None
|
||||||
|
for c_name, server_list in servers_dict.items():
|
||||||
|
if any(s["server_name"] == server_name for s in server_list):
|
||||||
|
cluster_name = c_name
|
||||||
|
break
|
||||||
|
|
||||||
|
if not cluster_name:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"❌ Не удалось найти кластер для сервера '{server_name}'.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
stmt_keys_count = select(func.count()).where(Key.server_id == server_name)
|
||||||
|
result = await session.execute(stmt_keys_count)
|
||||||
|
keys_count = result.scalar_one()
|
||||||
|
|
||||||
|
if keys_count > 0:
|
||||||
|
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
||||||
|
|
||||||
|
subq = (
|
||||||
|
select(func.count())
|
||||||
|
.where(Key.server_id == Server.server_name)
|
||||||
|
.correlate(Server)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt_all_servers = select(Server.server_name, subq.label("key_count")).where(
|
||||||
|
Server.server_name != server_name
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt_all_servers)
|
||||||
|
all_servers = result.all()
|
||||||
|
|
||||||
|
if all_servers:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for s_name, key_count in all_servers:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{s_name} ({key_count})",
|
||||||
|
callback_data=f"transfer_to_server|{s_name}|{server_name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=BACK,
|
||||||
|
callback_data=AdminServerCallback(
|
||||||
|
action="manage", data=server_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"⚠️ На сервере '{server_name}' есть {keys_count} ключей. Выберите сервер для переноса ключей:",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_server_transfer)
|
||||||
|
return
|
||||||
|
|
||||||
|
stmt_remaining = select(func.count()).where(
|
||||||
|
(Server.cluster_name == cluster_name) & (Server.server_name != server_name)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt_remaining)
|
||||||
|
remaining_servers = result.scalar_one()
|
||||||
|
|
||||||
|
if remaining_servers == 0:
|
||||||
|
stmt_other_clusters = (
|
||||||
|
select(Server.cluster_name)
|
||||||
|
.distinct()
|
||||||
|
.where(Server.cluster_name != cluster_name)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt_other_clusters)
|
||||||
|
other_clusters = result.scalars().all()
|
||||||
|
|
||||||
|
if other_clusters:
|
||||||
|
stmt_cluster_keys = select(func.count()).where(
|
||||||
|
Key.server_id == cluster_name
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt_cluster_keys)
|
||||||
|
cluster_keys_count = result.scalar_one()
|
||||||
|
|
||||||
|
if cluster_keys_count > 0:
|
||||||
|
await state.update_data(
|
||||||
|
server_name=server_name, cluster_name=cluster_name
|
||||||
|
)
|
||||||
|
|
||||||
|
subq_cluster = (
|
||||||
|
select(func.count())
|
||||||
|
.where(Key.server_id == Server.cluster_name)
|
||||||
|
.correlate(Server)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt_all_clusters = (
|
||||||
|
select(Server.cluster_name, subq_cluster.label("key_count"))
|
||||||
|
.where(Server.cluster_name != cluster_name)
|
||||||
|
.group_by(Server.cluster_name)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt_all_clusters)
|
||||||
|
all_clusters = result.all()
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for cl_name, key_count in all_clusters:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{cl_name} ({key_count})",
|
||||||
|
callback_data=f"transfer_to_cluster|{cl_name}|{cluster_name}|{server_name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=BACK,
|
||||||
|
callback_data=AdminServerCallback(
|
||||||
|
action="manage", data=server_name
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"⚠️ Это последний сервер в кластере '{cluster_name}'. На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
|
||||||
|
return
|
||||||
|
|
||||||
|
stmt_delete = delete(Server).where(
|
||||||
|
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
|
||||||
|
)
|
||||||
|
await session.execute(stmt_delete)
|
||||||
|
await session.commit()
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"✅ Сервер '{server_name}' удален. Кластер '{cluster_name}' также удален, так как в нем не осталось серверов.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt_delete = delete(Server).where(
|
||||||
|
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
|
||||||
|
)
|
||||||
|
await session.execute(stmt_delete)
|
||||||
|
await session.commit()
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=f"✅ Сервер '{server_name}' удален.",
|
||||||
|
reply_markup=build_admin_back_kb("clusters"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def toggle_server_enabled(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
callback_data: AdminServerCallback,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
server_name = callback_data.data
|
||||||
|
action = callback_data.action
|
||||||
|
|
||||||
|
new_status = action == "enable"
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Server)
|
||||||
|
.where(Server.server_name == server_name)
|
||||||
|
.values(enabled=new_status)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
|
||||||
|
cluster_name, server = next(
|
||||||
|
(
|
||||||
|
(c, s)
|
||||||
|
for c, cs in servers.items()
|
||||||
|
for s in cs
|
||||||
|
if s["server_name"] == server_name
|
||||||
|
),
|
||||||
|
(None, None),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not server:
|
||||||
|
await callback_query.message.edit_text("❌ Сервер не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
max_keys = server.get("max_keys")
|
||||||
|
limit_display = f"{max_keys}" if max_keys else "не задан"
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||||
|
f"<b>📡 API URL:</b> {server['api_url']}\n"
|
||||||
|
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
|
||||||
|
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
|
||||||
|
f"<b>📈 Лимит ключей:</b> {limit_display}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text,
|
||||||
|
reply_markup=build_manage_server_kb(
|
||||||
|
server_name, cluster_name, enabled=new_status
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def ask_server_limit(
|
||||||
|
callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext
|
||||||
|
):
|
||||||
|
server_name = callback_data.data
|
||||||
|
await state.set_state(ServerLimitState.waiting_for_limit)
|
||||||
|
await state.update_data(server_name=server_name)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
f"Введите лимит ключей для сервера <b>{server_name}</b> (целое число, 0 — без лимита):",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(ServerLimitState.waiting_for_limit, IsAdminFilter())
|
||||||
|
async def save_server_limit(
|
||||||
|
message: types.Message, state: FSMContext, session: AsyncSession
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
limit = int(message.text.strip())
|
||||||
|
if limit < 0:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
server_name = data["server_name"]
|
||||||
|
|
||||||
|
new_value = limit if limit > 0 else None
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Server)
|
||||||
|
.where(Server.server_name == server_name)
|
||||||
|
.values(max_keys=new_value)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
servers = await get_servers(session=session, include_enabled=True)
|
||||||
|
cluster_name, server = next(
|
||||||
|
(
|
||||||
|
(c, s)
|
||||||
|
for c, cs in servers.items()
|
||||||
|
for s in cs
|
||||||
|
if s["server_name"] == server_name
|
||||||
|
),
|
||||||
|
(None, None),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not server:
|
||||||
|
await message.answer("❌ Сервер не найден.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
max_keys = server.get("max_keys")
|
||||||
|
limit_display = f"{max_keys}" if max_keys is not None else "не задан"
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||||
|
f"<b>📡 API URL:</b> {server['api_url']}\n"
|
||||||
|
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
|
||||||
|
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
|
||||||
|
f"<b>📈 Лимит ключей:</b> {limit_display}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text,
|
||||||
|
reply_markup=build_manage_server_kb(
|
||||||
|
server_name, cluster_name, enabled=server.get("enabled", True)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .stats_handler import router
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||||
|
|
||||||
|
|
||||||
|
def build_stats_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack()
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="📥 Выгрузить пользователей в CSV",
|
||||||
|
callback_data=AdminPanelCallback(action="stats_export_users_csv").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="📥 Выгрузить оплаты в CSV",
|
||||||
|
callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="📥 Выгрузить подписки в CSV",
|
||||||
|
callback_data=AdminPanelCallback(action="stats_export_keys_csv").pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="📥 Выгрузить горящих лидов",
|
||||||
|
callback_data=AdminPanelCallback(action="stats_export_hot_leads_csv").pack(),
|
||||||
|
)
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
from bot import bot
|
||||||
|
from config import ADMIN_ID
|
||||||
|
from database import (
|
||||||
|
count_active_keys,
|
||||||
|
count_total_keys,
|
||||||
|
count_total_referrals,
|
||||||
|
count_total_users,
|
||||||
|
count_trial_keys,
|
||||||
|
count_users_registered_between,
|
||||||
|
count_users_registered_since,
|
||||||
|
count_users_updated_today,
|
||||||
|
get_tariff_distribution,
|
||||||
|
get_tariff_names,
|
||||||
|
get_tariff_groups,
|
||||||
|
get_tariff_durations,
|
||||||
|
sum_payments_between,
|
||||||
|
sum_payments_since,
|
||||||
|
sum_total_payments,
|
||||||
|
count_hot_leads
|
||||||
|
)
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
from logger import logger
|
||||||
|
from utils.csv_export import (
|
||||||
|
export_hot_leads_csv,
|
||||||
|
export_keys_csv,
|
||||||
|
export_payments_csv,
|
||||||
|
export_users_csv,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||||
|
from .keyboard import build_stats_kb
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminPanelCallback.filter(F.action == "stats"), IsAdminFilter())
|
||||||
|
async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
|
||||||
|
try:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
today = now.date()
|
||||||
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
total_users = await count_total_users(session)
|
||||||
|
users_updated_today = await count_users_updated_today(session, today)
|
||||||
|
registrations_today = await count_users_registered_since(session, today)
|
||||||
|
registrations_yesterday = await count_users_registered_between(
|
||||||
|
session, today - timedelta(days=1), today
|
||||||
|
)
|
||||||
|
registrations_week = await count_users_registered_since(
|
||||||
|
session, today - timedelta(days=today.weekday())
|
||||||
|
)
|
||||||
|
registrations_month = await count_users_registered_since(
|
||||||
|
session, today.replace(day=1)
|
||||||
|
)
|
||||||
|
last_month_start = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
|
||||||
|
registrations_last_month = await count_users_registered_between(
|
||||||
|
session, last_month_start, today.replace(day=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
total_keys = await count_total_keys(session)
|
||||||
|
active_keys = await count_active_keys(session)
|
||||||
|
expired_keys = total_keys - active_keys
|
||||||
|
trial_keys_count = await count_trial_keys(session)
|
||||||
|
|
||||||
|
tariff_counts, no_tariff_keys = await get_tariff_distribution(session, include_unbound=True)
|
||||||
|
tariff_names = await get_tariff_names(session, [tid for tid, _ in tariff_counts])
|
||||||
|
tariff_groups = await get_tariff_groups(session, [tid for tid, _ in tariff_counts])
|
||||||
|
tariff_durations = await get_tariff_durations(session, [tid for tid, _ in tariff_counts])
|
||||||
|
|
||||||
|
grouped_tariffs = {}
|
||||||
|
for tid, count in tariff_counts:
|
||||||
|
group = tariff_groups.get(tid, "unknown")
|
||||||
|
if group not in grouped_tariffs:
|
||||||
|
grouped_tariffs[group] = []
|
||||||
|
grouped_tariffs[group].append((tid, count))
|
||||||
|
|
||||||
|
tariff_stats_text = ""
|
||||||
|
duration_buckets = Counter()
|
||||||
|
now_ts = int(datetime.utcnow().timestamp() * 1000)
|
||||||
|
|
||||||
|
for key in no_tariff_keys:
|
||||||
|
duration_days = round((key["expiry_time"] - now_ts) / (1000 * 60 * 60 * 24))
|
||||||
|
if 25 <= duration_days <= 35:
|
||||||
|
bucket = "Без тарифа: 1 мес"
|
||||||
|
elif 80 <= duration_days <= 100:
|
||||||
|
bucket = "Без тарифа: 3 мес"
|
||||||
|
elif 170 <= duration_days <= 200:
|
||||||
|
bucket = "Без тарифа: 6 мес"
|
||||||
|
elif 350 <= duration_days <= 380:
|
||||||
|
bucket = "Без тарифа: 12 мес"
|
||||||
|
else:
|
||||||
|
bucket = "Без тарифа: прочее"
|
||||||
|
duration_buckets[bucket] += 1
|
||||||
|
|
||||||
|
bucket_order = {
|
||||||
|
"Без тарифа: 1 мес": 1,
|
||||||
|
"Без тарифа: 3 мес": 2,
|
||||||
|
"Без тарифа: 6 мес": 3,
|
||||||
|
"Без тарифа: 12 мес": 4,
|
||||||
|
"Без тарифа: прочее": 5
|
||||||
|
}
|
||||||
|
sorted_buckets = sorted(
|
||||||
|
duration_buckets.items(),
|
||||||
|
key=lambda x: bucket_order.get(x[0], 999)
|
||||||
|
)
|
||||||
|
|
||||||
|
for name, count in sorted_buckets:
|
||||||
|
tariff_stats_text += f"├ {name}: <b>{count}</b>\n"
|
||||||
|
|
||||||
|
for group, tariffs in grouped_tariffs.items():
|
||||||
|
tariff_stats_text += f"Тариф {group}\n"
|
||||||
|
sorted_tariffs = sorted(
|
||||||
|
tariffs,
|
||||||
|
key=lambda x: tariff_durations.get(x[0], 0)
|
||||||
|
)
|
||||||
|
for tid, count in sorted_tariffs:
|
||||||
|
name = tariff_names.get(tid, f"ID {tid}")
|
||||||
|
tariff_stats_text += f" ├ {name}: <b>{count}</b>\n"
|
||||||
|
|
||||||
|
tariff_stats_text = (
|
||||||
|
"└ По тарифам и срокам:\n" + tariff_stats_text
|
||||||
|
if tariff_stats_text
|
||||||
|
else "└ Нет данных по тарифам\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_referrals = await count_total_referrals(session)
|
||||||
|
|
||||||
|
total_payments_today = await sum_payments_since(session, today)
|
||||||
|
total_payments_yesterday = await sum_payments_between(
|
||||||
|
session, today - timedelta(days=1), today
|
||||||
|
)
|
||||||
|
total_payments_week = await sum_payments_since(
|
||||||
|
session, today - timedelta(days=today.weekday())
|
||||||
|
)
|
||||||
|
total_payments_month = await sum_payments_since(session, today.replace(day=1))
|
||||||
|
total_payments_last_month = await sum_payments_between(
|
||||||
|
session, last_month_start, today.replace(day=1)
|
||||||
|
)
|
||||||
|
total_payments_all_time = await sum_total_payments(session)
|
||||||
|
hot_leads_count = await count_hot_leads(session)
|
||||||
|
|
||||||
|
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
|
||||||
|
|
||||||
|
stats_message = (
|
||||||
|
f"📊 <b>Статистика проекта</b>\n\n"
|
||||||
|
f"👤 <b>Пользователи:</b>\n"
|
||||||
|
f"<blockquote>"
|
||||||
|
f"├ 🗓️ За день: <b>{registrations_today}</b>\n"
|
||||||
|
f"├ 🗓️ Вчера: <b>{registrations_yesterday}</b>\n"
|
||||||
|
f"├ 📆 За неделю: <b>{registrations_week}</b>\n"
|
||||||
|
f"├ 🗓️ За месяц: <b>{registrations_month}</b>\n"
|
||||||
|
f"├ 📅 За прошлый месяц: <b>{registrations_last_month}</b>\n"
|
||||||
|
f"└ 🌐 Всего: <b>{total_users}</b>\n"
|
||||||
|
f"</blockquote>\n"
|
||||||
|
f"💡 <b>Активность:</b>\n"
|
||||||
|
f"└ 👥 Сегодня были активны: <b>{users_updated_today}</b>\n\n"
|
||||||
|
f"🤝 <b>Реферальная система:</b>\n"
|
||||||
|
f"└ 👥 Всего привлечено: <b>{total_referrals}</b>\n\n"
|
||||||
|
f"🔐 <b>Подписки:</b>\n"
|
||||||
|
f"<blockquote>"
|
||||||
|
f"├ 📦 Всего сгенерировано: <b>{total_keys}</b>\n"
|
||||||
|
f"├ ✅ Активных: <b>{active_keys}</b>\n"
|
||||||
|
f"├ ❌ Просроченных: <b>{expired_keys}</b>\n"
|
||||||
|
f"├ 🧪 Триальных: <b>{trial_keys_count}</b>\n"
|
||||||
|
f"{tariff_stats_text}"
|
||||||
|
f"</blockquote>\n"
|
||||||
|
f"💰 <b>Финансы:</b>\n"
|
||||||
|
f"<blockquote>"
|
||||||
|
f"├ 📅 За день: <b>{total_payments_today} ₽</b>\n"
|
||||||
|
f"├ 📆 Вчера: <b>{total_payments_yesterday} ₽</b>\n"
|
||||||
|
f"├ 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
|
||||||
|
f"├ 📆 За месяц: <b>{total_payments_month} ₽</b>\n"
|
||||||
|
f"├ 📆 Прошлый месяц: <b>{total_payments_last_month} ₽</b>\n"
|
||||||
|
f"└ 🏦 Всего: <b>{total_payments_all_time} ₽</b>\n"
|
||||||
|
f"</blockquote>\n"
|
||||||
|
f"🔥 <b>Горячие лиды: {hot_leads_count}</b>\n"
|
||||||
|
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=stats_message, reply_markup=build_stats_kb()
|
||||||
|
)
|
||||||
|
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
if "message is not modified" not in str(e):
|
||||||
|
logger.error(f"Error in user_stats_menu: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in user_stats_menu: {e}")
|
||||||
|
await callback_query.answer(
|
||||||
|
"Произошла ошибка при получении статистики", show_alert=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "stats_export_users_csv"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_export_users_csv(callback_query: CallbackQuery, session: AsyncSession):
|
||||||
|
kb = build_admin_back_kb("stats")
|
||||||
|
try:
|
||||||
|
export = await export_users_csv(session)
|
||||||
|
await callback_query.message.answer_document(
|
||||||
|
document=export, caption="📅 Экспорт пользователей в CSV"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при экспорте пользователей: {e}")
|
||||||
|
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_export_payments_csv(
|
||||||
|
callback_query: CallbackQuery, session: AsyncSession
|
||||||
|
):
|
||||||
|
kb = build_admin_back_kb("stats")
|
||||||
|
try:
|
||||||
|
export = await export_payments_csv(session)
|
||||||
|
await callback_query.message.answer_document(
|
||||||
|
document=export, caption="📅 Экспорт платежей в CSV"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при экспорте платежей: {e}")
|
||||||
|
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_export_hot_leads_csv(
|
||||||
|
callback_query: CallbackQuery, session: AsyncSession
|
||||||
|
):
|
||||||
|
kb = build_admin_back_kb("stats")
|
||||||
|
try:
|
||||||
|
export = await export_hot_leads_csv(session)
|
||||||
|
await callback_query.message.answer_document(
|
||||||
|
document=export, caption="📅 Экспорт горящих лидов"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при экспорте горящих лидов: {e}")
|
||||||
|
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "stats_export_keys_csv"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_export_keys_csv(callback_query: CallbackQuery, session: AsyncSession):
|
||||||
|
kb = build_admin_back_kb("stats")
|
||||||
|
try:
|
||||||
|
export = await export_keys_csv(session)
|
||||||
|
await callback_query.message.answer_document(
|
||||||
|
document=export, caption="📅 Экспорт подписок в CSV"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при экспорте подписок: {e}")
|
||||||
|
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_daily_stats_report(session: AsyncSession):
|
||||||
|
try:
|
||||||
|
now_moscow = datetime.now(pytz.timezone("Europe/Moscow"))
|
||||||
|
update_time = now_moscow.strftime("%d.%m.%y %H:%M")
|
||||||
|
|
||||||
|
today = (
|
||||||
|
pytz.timezone("Europe/Moscow")
|
||||||
|
.localize(datetime.combine((now_moscow - timedelta(days=1)).date(), datetime.min.time()))
|
||||||
|
.astimezone(pytz.UTC)
|
||||||
|
.replace(tzinfo=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
registrations_today = await count_users_registered_since(session, today)
|
||||||
|
payments_today = await sum_payments_since(session, today)
|
||||||
|
active_keys = await count_active_keys(session)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"🗓️ <b>Сводка за день</b>\n\n"
|
||||||
|
f"👤 Новых пользователей: <b>{registrations_today}</b>\n"
|
||||||
|
f"💰 Оплачено: <b>{payments_today} ₽</b>\n"
|
||||||
|
f"🔐 Активных ключей: <b>{active_keys}</b>\n\n"
|
||||||
|
f"⏱️ <i>{update_time} МСК</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
for admin_id in ADMIN_ID:
|
||||||
|
await bot.send_message(admin_id, text)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Stats] Ошибка при отправке статистики: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "Сводка", IsAdminFilter())
|
||||||
|
async def test_stats_command(message: Message, session: AsyncSession):
|
||||||
|
await send_daily_stats_report(session)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .tariffs_handler import router
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTariffCallback(CallbackData, prefix="tariff"):
|
||||||
|
action: str
|
||||||
|
|
||||||
|
|
||||||
|
def build_tariff_menu_kb() -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🆕 Новый тариф",
|
||||||
|
callback_data=AdminTariffCallback(action="create").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📋 Мои тарифы",
|
||||||
|
callback_data=AdminTariffCallback(action="list").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад", callback_data=AdminPanelCallback(action="admin").pack()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_cancel_kb() -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Отменить", callback_data="cancel_tariff_creation"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_tariff_groups_kb(groups: list[str]) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for group in groups:
|
||||||
|
builder.button(
|
||||||
|
text=group,
|
||||||
|
callback_data=AdminTariffCallback(action=f"group|{group}").pack(),
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад", callback_data=AdminPanelCallback(action="tariffs").pack()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
if tariffs:
|
||||||
|
group_code = tariffs[0]["group_code"]
|
||||||
|
else:
|
||||||
|
group_code = "unknown"
|
||||||
|
|
||||||
|
for t in tariffs:
|
||||||
|
title = f"{t['name']} — {t['price_rub']}₽"
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=title,
|
||||||
|
callback_data=AdminTariffCallback(action=f"view|{t['id']}").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="➕ Добавить тариф",
|
||||||
|
callback_data=AdminTariffCallback(action=f"create|{group_code}").pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад", callback_data=AdminTariffCallback(action="list").pack()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_single_tariff_kb(tariff_id: int) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✏️ Редактировать",
|
||||||
|
callback_data=AdminTariffCallback(
|
||||||
|
action=f"edit|{tariff_id}"
|
||||||
|
).pack(),
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🗑 Удалить",
|
||||||
|
callback_data=AdminTariffCallback(
|
||||||
|
action=f"delete|{tariff_id}"
|
||||||
|
).pack(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад",
|
||||||
|
callback_data=AdminTariffCallback(action="list").pack(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_edit_tariff_fields_kb(tariff_id: int) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📝 Название", callback_data=f"edit_field|{tariff_id}|name"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📅 Длительность",
|
||||||
|
callback_data=f"edit_field|{tariff_id}|duration_days",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="💰 Цена", callback_data=f"edit_field|{tariff_id}|price_rub"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📦 Трафик (ГБ или 0)",
|
||||||
|
callback_data=f"edit_field|{tariff_id}|traffic_limit",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📱 Лимит устройств",
|
||||||
|
callback_data=f"edit_field|{tariff_id}|device_limit",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔘 Активность", callback_data=f"toggle_active|{tariff_id}"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад",
|
||||||
|
callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
|
||||||
|
)]
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
InlineKeyboardButton,
|
||||||
|
InlineKeyboardMarkup,
|
||||||
|
Message,
|
||||||
|
)
|
||||||
|
from sqlalchemy import delete, distinct, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import create_tariff
|
||||||
|
from database.models import Key, Server, Tariff
|
||||||
|
from filters.admin import IsAdminFilter
|
||||||
|
|
||||||
|
from ..panel.keyboard import AdminPanelCallback
|
||||||
|
from .keyboard import (
|
||||||
|
AdminTariffCallback,
|
||||||
|
build_cancel_kb,
|
||||||
|
build_edit_tariff_fields_kb,
|
||||||
|
build_single_tariff_kb,
|
||||||
|
build_tariff_groups_kb,
|
||||||
|
build_tariff_list_kb,
|
||||||
|
build_tariff_menu_kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class TariffCreateState(StatesGroup):
|
||||||
|
group = State()
|
||||||
|
name = State()
|
||||||
|
duration = State()
|
||||||
|
price = State()
|
||||||
|
traffic = State()
|
||||||
|
confirm_more = State()
|
||||||
|
device_limit = State()
|
||||||
|
|
||||||
|
|
||||||
|
class TariffEditState(StatesGroup):
|
||||||
|
choosing_field = State()
|
||||||
|
editing_value = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def handle_tariff_menu(callback_query: CallbackQuery):
|
||||||
|
text = (
|
||||||
|
"<b>💸 Управление тарифами</b>\n\n"
|
||||||
|
"Вы можете выполнить следующие действия:\n\n"
|
||||||
|
"<b>🆕 Создать тариф</b>\n"
|
||||||
|
"• Установите длительность (в днях)\n"
|
||||||
|
"• Задайте цену (в рублях)\n"
|
||||||
|
"• Задайте лимит устройств (hwid/ip_limit)\n"
|
||||||
|
"• Укажите лимит трафика (в ГБ)\n\n"
|
||||||
|
"<b>📋 Редактировать тарифы</b>\n"
|
||||||
|
"• Просматривайте список текущих тарифов\n"
|
||||||
|
"• Изменяйте параметры или удаляйте при необходимости"
|
||||||
|
)
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
text=text, reply_markup=build_tariff_menu_kb()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminTariffCallback.filter(F.action == "create"), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def start_tariff_creation(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await state.set_state(TariffCreateState.group)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📁 Введите <b>код группы</b>, в которую вы хотите добавить тариф.\n\n"
|
||||||
|
"Например: <code>basic</code>, <code>vip</code>, <code>business</code>\n\n"
|
||||||
|
"<b>Специальные группы:</b>\n"
|
||||||
|
"• <code>discounts</code> — тарифы со скидкой\n"
|
||||||
|
"• <code>discounts_max</code> — тарифы с максимальной скидкой\n"
|
||||||
|
"• <code>gifts</code> — тарифы для подарков",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.group, IsAdminFilter())
|
||||||
|
async def process_tariff_group(message: Message, state: FSMContext):
|
||||||
|
group_code = message.text.strip().lower()
|
||||||
|
await state.update_data(group_code=group_code)
|
||||||
|
await state.set_state(TariffCreateState.name)
|
||||||
|
await message.answer(
|
||||||
|
"📝 Введите <b>название тарифа</b>\n\n"
|
||||||
|
"Например: <i>30 дней</i> или <i>1 месяц</i>\n\n"
|
||||||
|
"<i>Это название будет отображаться пользователю при выборе тарифа</i>",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.name, IsAdminFilter())
|
||||||
|
async def process_tariff_name(message: Message, state: FSMContext):
|
||||||
|
await state.update_data(name=message.text.strip())
|
||||||
|
await state.set_state(TariffCreateState.duration)
|
||||||
|
await message.answer(
|
||||||
|
"📅 Введите <b>длительность тарифа в днях</b> (например: <i>30</i>):",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.duration, IsAdminFilter())
|
||||||
|
async def process_tariff_duration(message: Message, state: FSMContext):
|
||||||
|
try:
|
||||||
|
days = int(message.text.strip())
|
||||||
|
if days <= 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Введите корректное количество дней (целое число больше 0):"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(duration_days=days)
|
||||||
|
await state.set_state(TariffCreateState.price)
|
||||||
|
await message.answer(
|
||||||
|
"💰 Введите <b>цену тарифа в рублях</b> (например: <i>150</i>)\n\n"
|
||||||
|
"<i>Будет показано клиенту при выборе тарифа</i>",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.price, IsAdminFilter())
|
||||||
|
async def process_tariff_price(message: Message, state: FSMContext):
|
||||||
|
try:
|
||||||
|
price = int(message.text.strip())
|
||||||
|
if price < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Введите корректную цену (целое число 0 или больше):")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(price_rub=price)
|
||||||
|
await state.set_state(TariffCreateState.traffic)
|
||||||
|
await message.answer(
|
||||||
|
"📦 Введите <b>лимит трафика в ГБ</b> (например: <i>100</i>, 0 — безлимит):",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.traffic, IsAdminFilter())
|
||||||
|
async def process_tariff_traffic(message: Message, state: FSMContext):
|
||||||
|
try:
|
||||||
|
traffic = int(message.text.strip())
|
||||||
|
if traffic < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Введите корректный лимит трафика (целое число 0 или больше):"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(traffic_limit=traffic if traffic > 0 else None)
|
||||||
|
await state.set_state(TariffCreateState.device_limit)
|
||||||
|
await message.answer(
|
||||||
|
"📱 Введите <b>лимит устройств (HWID)</b> для тарифа (например: <i>3</i>, 0 — безлимит):",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffCreateState.device_limit, IsAdminFilter())
|
||||||
|
async def process_tariff_device_limit(
|
||||||
|
message: Message, state: FSMContext, session: AsyncSession
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
device_limit = int(message.text.strip())
|
||||||
|
if device_limit < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Введите корректный лимит устройств (целое число 0 или больше):"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
|
||||||
|
new_tariff = await create_tariff(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"name": data["name"],
|
||||||
|
"group_code": data["group_code"],
|
||||||
|
"duration_days": data["duration_days"],
|
||||||
|
"price_rub": data["price_rub"],
|
||||||
|
"traffic_limit": data["traffic_limit"],
|
||||||
|
"device_limit": device_limit if device_limit > 0 else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(TariffCreateState.confirm_more)
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Тариф <b>{new_tariff.name}</b> добавлен в группу <code>{data['group_code']}</code>.\n\n"
|
||||||
|
"➕ Хотите добавить ещё один тариф в эту группу?",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="✅ Да", callback_data="add_more_tariff"),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Нет", callback_data="done_tariff_group"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "add_more_tariff", IsAdminFilter())
|
||||||
|
async def handle_add_more_tariff(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await state.set_state(TariffCreateState.name)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📝 Введите <b>название следующего тарифа</b>:", reply_markup=build_cancel_kb()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "done_tariff_group", IsAdminFilter())
|
||||||
|
async def handle_done_tariff_group(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"✅ Группа тарифов успешно завершена.", reply_markup=build_tariff_menu_kb()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "cancel_tariff_creation", IsAdminFilter())
|
||||||
|
async def cancel_tariff_creation(callback: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"❌ Создание тарифа отменено.", reply_markup=build_tariff_menu_kb()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(AdminTariffCallback.filter(F.action == "list"), IsAdminFilter())
|
||||||
|
async def show_tariff_groups(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
result = await session.execute(
|
||||||
|
select(distinct(Tariff.group_code))
|
||||||
|
.where(Tariff.group_code.isnot(None))
|
||||||
|
.order_by(Tariff.group_code)
|
||||||
|
)
|
||||||
|
groups = [row[0] for row in result.fetchall()]
|
||||||
|
|
||||||
|
if not groups:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"❌ Нет сохранённых тарифов.", reply_markup=build_tariff_menu_kb()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
special_groups = {
|
||||||
|
"discounts": "🔻 Скидки",
|
||||||
|
"discounts_max": "🔻 Макс. скидки",
|
||||||
|
"gifts": "🎁 Подарки",
|
||||||
|
}
|
||||||
|
|
||||||
|
text = "<b>📋 Выберите тарифную группу:</b>\n\n"
|
||||||
|
text += "<b>Специальные группы:</b>\n"
|
||||||
|
for code, label in special_groups.items():
|
||||||
|
status = "✅ создана" if code in groups else "❌ не создана"
|
||||||
|
text += f"{label} — <code>{code}</code> — <b>{status}</b>\n"
|
||||||
|
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
|
await callback.message.edit_text(text, reply_markup=build_tariff_groups_kb(groups))
|
||||||
|
|
||||||
|
|
||||||
|
def tariff_to_dict(tariff: Tariff) -> dict:
|
||||||
|
return {
|
||||||
|
"id": tariff.id,
|
||||||
|
"name": tariff.name,
|
||||||
|
"price_rub": tariff.price_rub,
|
||||||
|
"group_code": tariff.group_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def show_tariffs_in_group(
|
||||||
|
callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession
|
||||||
|
):
|
||||||
|
group_code = callback_data.action.split("|", 1)[1]
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id)
|
||||||
|
)
|
||||||
|
tariffs = result.scalars().all()
|
||||||
|
|
||||||
|
if not tariffs:
|
||||||
|
await callback.message.edit_text("❌ В этой группе пока нет тарифов.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tariff_dicts = [tariff_to_dict(t) for t in tariffs]
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"<b>📦 Тарифы группы: {group_code}</b>",
|
||||||
|
reply_markup=build_tariff_list_kb(tariff_dicts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminTariffCallback.filter(F.action.startswith("view|")), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def view_tariff(
|
||||||
|
callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession
|
||||||
|
):
|
||||||
|
tariff_id = int(callback_data.action.split("|", 1)[1])
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await callback.message.edit_text("❌ Тариф не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
text, markup = render_tariff_card(tariff)
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminTariffCallback.filter(F.action.startswith("delete|")), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def confirm_tariff_deletion(
|
||||||
|
callback: CallbackQuery, callback_data: AdminTariffCallback
|
||||||
|
):
|
||||||
|
tariff_id = int(callback_data.action.split("|", 1)[1])
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"⚠️ Вы уверены, что хотите <b>удалить</b> этот тариф?",
|
||||||
|
reply_markup=InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Отмена", callback_data=f"view|{tariff_id}"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("confirm_delete_tariff|"), IsAdminFilter())
|
||||||
|
async def delete_tariff(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
tariff_id = int(callback.data.split("|", 1)[1])
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await callback.message.edit_text("❌ Тариф не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
group_code = tariff.group_code
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Key).where(Key.tariff_id == tariff_id).values(tariff_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff).where(Tariff.group_code == group_code)
|
||||||
|
)
|
||||||
|
remaining_tariffs = result.scalars().all()
|
||||||
|
|
||||||
|
if not remaining_tariffs:
|
||||||
|
await session.execute(
|
||||||
|
update(Server)
|
||||||
|
.where(Server.tariff_group == group_code)
|
||||||
|
.values(tariff_group=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"🗑 Тариф успешно удалён.", reply_markup=build_tariff_menu_kb()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminTariffCallback.filter(F.action.startswith("edit|")), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def start_edit_tariff(
|
||||||
|
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
|
||||||
|
):
|
||||||
|
tariff_id = int(callback_data.action.split("|")[1])
|
||||||
|
await state.update_data(tariff_id=tariff_id)
|
||||||
|
await state.set_state(TariffEditState.choosing_field)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"<b>✏️ Что вы хотите изменить?</b>",
|
||||||
|
reply_markup=build_edit_tariff_fields_kb(tariff_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("edit_field|"), IsAdminFilter())
|
||||||
|
async def ask_new_value(callback: CallbackQuery, state: FSMContext):
|
||||||
|
_, _tariff_id, field = callback.data.split("|")
|
||||||
|
await state.update_data(field=field)
|
||||||
|
await state.set_state(TariffEditState.editing_value)
|
||||||
|
|
||||||
|
field_names = {
|
||||||
|
"name": "название тарифа",
|
||||||
|
"duration_days": "длительность в днях",
|
||||||
|
"price_rub": "цену в рублях",
|
||||||
|
"traffic_limit": "лимит трафика в ГБ (0 — безлимит)",
|
||||||
|
"device_limit": "лимит устройств (0 — безлимит)",
|
||||||
|
}
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(TariffEditState.editing_value, IsAdminFilter())
|
||||||
|
async def apply_edit(message: Message, state: FSMContext, session: AsyncSession):
|
||||||
|
data = await state.get_data()
|
||||||
|
tariff_id = data["tariff_id"]
|
||||||
|
field = data["field"]
|
||||||
|
value = message.text.strip()
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await message.answer("❌ Тариф не найден.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
if field in ["duration_days", "price_rub", "traffic_limit", "device_limit"]:
|
||||||
|
try:
|
||||||
|
num = int(value)
|
||||||
|
if num < 0:
|
||||||
|
raise ValueError
|
||||||
|
if field in ["traffic_limit", "device_limit"]:
|
||||||
|
value = num if num > 0 else None
|
||||||
|
else:
|
||||||
|
value = num
|
||||||
|
except ValueError:
|
||||||
|
await message.answer("❌ Введите корректное число.")
|
||||||
|
return
|
||||||
|
|
||||||
|
setattr(tariff, field, value)
|
||||||
|
tariff.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
text, markup = render_tariff_card(tariff)
|
||||||
|
await message.answer(text=text, reply_markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("toggle_active|"), IsAdminFilter())
|
||||||
|
async def toggle_tariff_status(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
tariff_id = int(callback.data.split("|")[1])
|
||||||
|
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
await callback.message.edit_text("❌ Тариф не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tariff.is_active = not tariff.is_active
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
text, markup = render_tariff_card(tariff)
|
||||||
|
await callback.message.edit_text(text=text, reply_markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
AdminTariffCallback.filter(F.action.startswith("create|")), IsAdminFilter()
|
||||||
|
)
|
||||||
|
async def start_tariff_creation_existing_group(
|
||||||
|
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
|
||||||
|
):
|
||||||
|
group_code = callback_data.action.split("|", 1)[1]
|
||||||
|
await state.update_data(group_code=group_code)
|
||||||
|
await state.set_state(TariffCreateState.name)
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"📦 Добавление нового тарифа в группу <code>{group_code}</code>\n\n📝 Введите <b>название тарифа</b>:",
|
||||||
|
reply_markup=build_cancel_kb(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
|
||||||
|
traffic_text = (
|
||||||
|
f"{tariff.traffic_limit} ГБ" if tariff.traffic_limit else "Безлимит"
|
||||||
|
)
|
||||||
|
device_text = (
|
||||||
|
f"{tariff.device_limit}" if tariff.device_limit is not None else "Безлимит"
|
||||||
|
)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"<b>📄 Тариф: {tariff.name}</b>\n\n"
|
||||||
|
f"📁 Группа: <code>{tariff.group_code}</code>\n"
|
||||||
|
f"📅 Длительность: <b>{tariff.duration_days} дней</b>\n"
|
||||||
|
f"💰 Стоимость: <b>{tariff.price_rub}₽</b>\n"
|
||||||
|
f"📦 Трафик: <b>{traffic_text}</b>\n"
|
||||||
|
f"📱 Устройств: <b>{device_text}</b>\n"
|
||||||
|
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return text, build_single_tariff_kb(tariff.id)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from .users_handler import router
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from aiogram.filters.callback_data import CallbackData
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import HWID_RESET_BUTTON
|
||||||
|
from database import get_clusters
|
||||||
|
from database.models import Key, Server, Tariff
|
||||||
|
from handlers.buttons import BACK
|
||||||
|
from handlers.utils import format_days
|
||||||
|
|
||||||
|
from ..panel.keyboard import build_admin_back_btn
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserEditorCallback(CallbackData, prefix="admin_users"):
|
||||||
|
action: str
|
||||||
|
tg_id: int
|
||||||
|
data: str | int | None = None
|
||||||
|
edit: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
|
||||||
|
action: str
|
||||||
|
tg_id: int
|
||||||
|
data: str
|
||||||
|
month: int | None = None
|
||||||
|
edit: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_edit_kb(
|
||||||
|
tg_id: int, key_records: list, is_banned: bool = False
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
current_time = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text="➕ Создать ключ",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_create_key", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for record in key_records:
|
||||||
|
email = record.email
|
||||||
|
expiry = datetime.fromtimestamp(record.expiry_time / 1000, tz=timezone.utc)
|
||||||
|
days = (expiry - current_time).days
|
||||||
|
builder.button(
|
||||||
|
text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_key_edit", tg_id=tg_id, data=str(email)
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text="✉️ Сообщение",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_send_message", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="💸 Изменить баланс",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_balance_edit", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🤝 Выгрузить рефералов",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_export_referrals", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="♻️ Восстановить триал",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_trial_restore", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="❌ Удалить клиента",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_delete_user", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="✅ Разблокировать" if is_banned else "🚫 Заблокировать",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_unban" if is_banned else "users_ban", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.row(build_editor_btn("🔄 Обновить данные", tg_id, edit=True))
|
||||||
|
builder.row(build_admin_back_btn())
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text=BACK,
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_balance_edit", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
async def build_users_balance_kb(
|
||||||
|
session: AsyncSession, tg_id: int
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
for amount in [100, 250, 500, 1000]:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"+ {amount}₽",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_balance_add", tg_id=tg_id, data=amount
|
||||||
|
).pack(),
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"- {amount}₽",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_balance_add", tg_id=tg_id, data=-amount
|
||||||
|
).pack(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="💵 Добавить",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_balance_add", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="💵 Вычесть",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_balance_take", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="💵 Установить баланс",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_balance_set", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(build_editor_back_btn(tg_id, True))
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text=BACK,
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_key_edit", tg_id=tg_id, data=email, edit=True
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
async def build_users_key_expiry_kb(
|
||||||
|
session: AsyncSession, tg_id: int, email: str
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
result = await session.execute(select(Key.server_id, Key.tariff_id).where(Key.email == email))
|
||||||
|
row = result.first()
|
||||||
|
server_id, tariff_id = (row if row else (None, None))
|
||||||
|
|
||||||
|
if tariff_id:
|
||||||
|
result = await session.execute(select(Tariff.group_code).where(Tariff.id == tariff_id))
|
||||||
|
row = result.first()
|
||||||
|
if row and row[0]:
|
||||||
|
group_code = row[0]
|
||||||
|
result = await session.execute(
|
||||||
|
select(Tariff)
|
||||||
|
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
|
||||||
|
)
|
||||||
|
tariffs = result.scalars().all()
|
||||||
|
unique_durations = set()
|
||||||
|
for tariff in tariffs:
|
||||||
|
days = tariff.duration_days
|
||||||
|
if days < 1 or days in unique_durations:
|
||||||
|
continue
|
||||||
|
unique_durations.add(days)
|
||||||
|
label = format_days(days)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"+ {label}",
|
||||||
|
callback_data=AdminUserKeyEditorCallback(
|
||||||
|
action="add", tg_id=tg_id, data=email, month=days
|
||||||
|
).pack(),
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"- {label}",
|
||||||
|
callback_data=AdminUserKeyEditorCallback(
|
||||||
|
action="add", tg_id=tg_id, data=email, month=-days
|
||||||
|
).pack(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⏳ Добавить дни",
|
||||||
|
callback_data=AdminUserKeyEditorCallback(
|
||||||
|
action="add", tg_id=tg_id, data=email
|
||||||
|
).pack(),
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⏳ Вычесть дни",
|
||||||
|
callback_data=AdminUserKeyEditorCallback(
|
||||||
|
action="take", tg_id=tg_id, data=email
|
||||||
|
).pack(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⏳ Установить дату истечения",
|
||||||
|
callback_data=AdminUserKeyEditorCallback(
|
||||||
|
action="set", tg_id=tg_id, data=email
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=BACK,
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_key_edit", tg_id=tg_id, data=email
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_delete_kb(tg_id: int):
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="❌ Да, удалить!",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_delete_user_confirm", tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.row(build_editor_back_btn(tg_id, True))
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text=BACK,
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_key_edit", tg_id=tg_id, data=email
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text="⏳ Время истечения",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_expiry_edit", data=email, tg_id=key_details["tg_id"]
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🔄 Перевыпустить",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_update_key", data=email, tg_id=key_details["tg_id"]
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="📦 Тариф",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_renew", data=email, tg_id=key_details["tg_id"]
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="❌ Удалить",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_delete_key", data=email, tg_id=key_details["tg_id"]
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="📊 Трафик",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_traffic", data=email, tg_id=key_details["tg_id"]
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="♻️ Сбросить трафик",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_reset_traffic", data=email, tg_id=key_details["tg_id"]
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
if HWID_RESET_BUTTON:
|
||||||
|
builder.button(
|
||||||
|
text="💻 HWID",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_hwid_menu", data=email, tg_id=key_details["tg_id"]
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(build_editor_back_btn(key_details["tg_id"], True))
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_hwid_menu_kb(email: str, tg_id: int) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.button(
|
||||||
|
text="♻️ Сбросить HWID",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_hwid_reset", data=email, tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🔙 Назад",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_key_edit", data=email, tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✅ Да, удалить",
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_delete_key_confirm", data=email, tg_id=tg_id
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(build_editor_back_btn(tg_id))
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
|
||||||
|
return build_editor_singleton_kb(BACK, tg_id, edit)
|
||||||
|
|
||||||
|
|
||||||
|
def build_editor_singleton_kb(
|
||||||
|
text: str, tg_id: int, edit: bool = False
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(build_editor_btn(text, tg_id, edit))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButton:
|
||||||
|
return build_editor_btn(BACK, tg_id, edit)
|
||||||
|
|
||||||
|
|
||||||
|
def build_editor_btn(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardButton:
|
||||||
|
return InlineKeyboardButton(
|
||||||
|
text=text,
|
||||||
|
callback_data=AdminUserEditorCallback(
|
||||||
|
action="users_editor", tg_id=tg_id, edit=edit
|
||||||
|
).pack(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_cluster_selection_kb(
|
||||||
|
session, tg_id: int, email: str, action: str
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
clusters = await get_clusters(session)
|
||||||
|
|
||||||
|
for cluster_id in clusters:
|
||||||
|
builder.button(
|
||||||
|
text=cluster_id, callback_data=f"{action}|{tg_id}|{email}|{cluster_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.button(text=BACK, callback_data=f"edit_user_key|{tg_id}|{email}")
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
|||||||
import aiosqlite
|
|
||||||
from aiogram import Router, types
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
@router.callback_query(lambda c: c.data == 'view_balance')
|
|
||||||
async def process_callback_view_balance(callback_query: types.CallbackQuery):
|
|
||||||
tg_id = callback_query.from_user.id
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Основные кнопки меню:
|
||||||
|
|
||||||
|
MAIN_MENU = "👤 Личный кабинет"
|
||||||
|
BACK = "⬅️ Назад"
|
||||||
|
SUPPORT = "💬 Поддержка"
|
||||||
|
CHANNEL = "📢 Канал"
|
||||||
|
ABOUT_VPN = "💬 О сервисе"
|
||||||
|
APPLY = "✅ Подтвердить"
|
||||||
|
CANCEL = "❌ Отмена"
|
||||||
|
|
||||||
|
# Профиль
|
||||||
|
|
||||||
|
ADD_SUB = "➕ Добавить новую подписку"
|
||||||
|
MY_SUBS = "📱 Мои подписки"
|
||||||
|
BALANCE = "💵 Баланс"
|
||||||
|
INVITE = "👥 Пригласить"
|
||||||
|
GIFTS = "🎁 Подарить"
|
||||||
|
INSTRUCTIONS = "📘 Инструкции"
|
||||||
|
TOP_FIVE = "🏆 Топ-5"
|
||||||
|
TRIAL_SUB = "🎁 Пробная подписка"
|
||||||
|
MY_SUB = "🔐 Моя подписка"
|
||||||
|
|
||||||
|
# Меню Оплат и баланса
|
||||||
|
|
||||||
|
BALANCE_HISTORY = "📊 История пополнения"
|
||||||
|
PAYMENT = "💳 Пополнить баланс"
|
||||||
|
COUPON = "🎟️ Активировать купон"
|
||||||
|
COUPON_RESTART = "🎟️ Попробовать другой купон"
|
||||||
|
YOOKASSA = "💳 ЮКасса: быстрая оплата"
|
||||||
|
YOOMONEY = "💳 ЮМани: перевод по карте"
|
||||||
|
CRYPTOBOT = "💰 CryptoBot: криптовалюта"
|
||||||
|
STARS = "⭐ Оплата Звездами"
|
||||||
|
ROBOKASSA = "⭐ RoboKassa"
|
||||||
|
DISCOUNT_TARIFF = "🔥 Получить скидку"
|
||||||
|
MAX_DISCOUNT_TARIFF = "⚡ Получить максимальную скидку"
|
||||||
|
|
||||||
|
# Кнопки подписки на канал
|
||||||
|
SUB_CHANELL = "📢 Подписаться"
|
||||||
|
SUB_CHANELL_DONE = "✅ Я подписался"
|
||||||
|
|
||||||
|
# Подарки
|
||||||
|
|
||||||
|
GIFT = "🎁 Подарить подписку"
|
||||||
|
MY_GIFTS = "🎁 Мои подарки"
|
||||||
|
GIFTS_MENU = "В меню подарков"
|
||||||
|
SHARE_GIFT = "🎁 Поделиться подарком"
|
||||||
|
GET_GIFT = "🎁 Получить подарок"
|
||||||
|
|
||||||
|
# Создание и подключение ключей
|
||||||
|
|
||||||
|
DOWNLOAD_IOS_BUTTON = "🍏 Скачать iOS"
|
||||||
|
DOWNLOAD_ANDROID_BUTTON = "🤖 Скачать Android"
|
||||||
|
DOWNLOAD_MACOS_BUTTON = "🍏 Скачать macOS"
|
||||||
|
DOWNLOAD_PC_BUTTON = "💻 Скачать Windows"
|
||||||
|
IMPORT_IOS = "🍏 Подключить"
|
||||||
|
IMPORT_ANDROID = "🤖 Подключить"
|
||||||
|
PC_BUTTON = "💻 Компьютеры"
|
||||||
|
TV_BUTTON = "📺 Андроид TV"
|
||||||
|
CONNECT_PHONE = "📱 Подключить телефон"
|
||||||
|
CONNECT_DEVICE = "📲 Подключить устройство"
|
||||||
|
CONNECT_WINDOWS_BUTTON = "💻 Подключить"
|
||||||
|
CONNECT_MACOS_BUTTON = "🍏 Подключить"
|
||||||
|
ALIAS = "✏️"
|
||||||
|
UNFREEZE = "🟢 Разморозить подписку"
|
||||||
|
FREEZE = "🛑 Заморозить подписку"
|
||||||
|
RENEW = "⏳ Продлить"
|
||||||
|
RENEW_FULL = "⏳ Продлить подписку"
|
||||||
|
DELETE = "❌ Удалить"
|
||||||
|
CHANGE_LOCATION = "🌍 Сменить локацию"
|
||||||
|
QR = "📷 Показать QR-код"
|
||||||
|
IPHONE = "🍏 Айфон"
|
||||||
|
ANDROID = "🤖 Андроид"
|
||||||
|
PC = "💻 Компьютер"
|
||||||
|
PC_PC = "💻 Windows"
|
||||||
|
PC_MACOS = "🍏 macOS"
|
||||||
|
TV = "📺 Телевизор"
|
||||||
|
ROUTER = "📶 Роутер"
|
||||||
|
MANUAL_INSTRUCTIONS = "📖 Ручная установка"
|
||||||
|
RENEW_KEY = "🔄 Продлить подписку"
|
||||||
|
TV_CONTINUE = "▶ Продолжить"
|
||||||
|
TV_INSTRUCTIONS = "📖 Полная инструкция"
|
||||||
|
HWID_BUTTON = "♻️ Сбросить привязку"
|
||||||
|
|
||||||
|
|
||||||
|
# Кнопки касс
|
||||||
|
|
||||||
|
PAY = "Пополнить"
|
||||||
|
PAY_2 = "Оплатить"
|
||||||
|
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
|
||||||
|
STARS_BOT = "🤖 Бот для покупки звезд"
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import random
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from handlers.texts import CAPTCHA_EMOJIS, CAPTCHA_PROMPT_MSG
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from .utils import edit_or_send_message
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_captcha(message: Message, state: FSMContext):
|
||||||
|
correct_emoji, correct_text = secrets.choice(list(CAPTCHA_EMOJIS.items()))
|
||||||
|
wrong_emojis = random.sample(
|
||||||
|
[e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3
|
||||||
|
)
|
||||||
|
|
||||||
|
all_emojis = [correct_emoji] + wrong_emojis
|
||||||
|
random.shuffle(all_emojis)
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
|
||||||
|
if "user_data" not in state_data:
|
||||||
|
from_user = message.from_user
|
||||||
|
if not from_user:
|
||||||
|
logger.warning(
|
||||||
|
"[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
user_data={
|
||||||
|
"tg_id": from_user.id,
|
||||||
|
"username": getattr(from_user, "username", None),
|
||||||
|
"first_name": getattr(from_user, "first_name", None),
|
||||||
|
"last_name": getattr(from_user, "last_name", None),
|
||||||
|
"language_code": getattr(from_user, "language_code", None),
|
||||||
|
"is_bot": getattr(from_user, "is_bot", False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = {
|
||||||
|
"correct_emoji": correct_emoji,
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"chat_id": message.chat.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
if "original_text" not in state_data:
|
||||||
|
update_data["original_text"] = message.text
|
||||||
|
|
||||||
|
await state.update_data(**update_data)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for emoji in all_emojis:
|
||||||
|
builder.button(text=emoji, callback_data=f"captcha_{emoji}")
|
||||||
|
builder.adjust(2, 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": CAPTCHA_PROMPT_MSG.format(correct_text=correct_text),
|
||||||
|
"markup": builder.as_markup(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("captcha_"))
|
||||||
|
async def check_captcha(
|
||||||
|
callback: CallbackQuery, state: FSMContext, session: Any, admin: bool
|
||||||
|
):
|
||||||
|
from handlers.start import process_start_logic
|
||||||
|
|
||||||
|
selected_emoji = callback.data.split("captcha_")[1]
|
||||||
|
state_data = await state.get_data()
|
||||||
|
correct_emoji = state_data.get("correct_emoji")
|
||||||
|
original_text = state_data.get("original_text")
|
||||||
|
user_data = state_data.get("user_data")
|
||||||
|
|
||||||
|
target_message = callback.message
|
||||||
|
|
||||||
|
if selected_emoji == correct_emoji:
|
||||||
|
logger.info(f"Пользователь {callback.from_user.id} успешно прошел капчу")
|
||||||
|
logger.debug(f"[CAPTCHA] user_data передано в process_start_logic: {user_data}")
|
||||||
|
await process_start_logic(
|
||||||
|
message=target_message,
|
||||||
|
state=state,
|
||||||
|
session=session,
|
||||||
|
admin=admin,
|
||||||
|
text_to_process=original_text,
|
||||||
|
user_data=user_data,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Пользователь {callback.from_user.id} неверно ответил на капчу")
|
||||||
|
captcha = await generate_captcha(target_message, state)
|
||||||
|
if captcha:
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=target_message,
|
||||||
|
text=captcha["text"],
|
||||||
|
reply_markup=captcha["markup"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import html
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import ADMIN_ID
|
||||||
|
from database import (
|
||||||
|
add_user,
|
||||||
|
check_coupon_usage,
|
||||||
|
check_user_exists,
|
||||||
|
create_coupon_usage,
|
||||||
|
get_coupon_by_code,
|
||||||
|
get_keys,
|
||||||
|
update_balance,
|
||||||
|
update_coupon_usage_count,
|
||||||
|
update_key_expiry,
|
||||||
|
add_payment,
|
||||||
|
get_tariff_by_id,
|
||||||
|
)
|
||||||
|
from handlers.buttons import MAIN_MENU
|
||||||
|
from handlers.keys.key_utils import renew_key_in_cluster
|
||||||
|
from handlers.profile import process_callback_view_profile
|
||||||
|
from handlers.texts import (
|
||||||
|
COUPON_ALREADY_USED_MSG,
|
||||||
|
COUPON_INPUT_PROMPT,
|
||||||
|
COUPON_NOT_FOUND_MSG,
|
||||||
|
)
|
||||||
|
from handlers.utils import edit_or_send_message, format_days
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class CouponActivationState(StatesGroup):
|
||||||
|
waiting_for_coupon_code = State()
|
||||||
|
waiting_for_key_selection = State()
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "activate_coupon")
|
||||||
|
@router.message(F.text == "/activate_coupon")
|
||||||
|
async def handle_activate_coupon(
|
||||||
|
callback_query_or_message: Message | CallbackQuery, state: FSMContext
|
||||||
|
):
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
if isinstance(callback_query_or_message, CallbackQuery):
|
||||||
|
target_message = callback_query_or_message.message
|
||||||
|
else:
|
||||||
|
target_message = callback_query_or_message
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=target_message,
|
||||||
|
text=COUPON_INPUT_PROMPT,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
|
await state.set_state(CouponActivationState.waiting_for_coupon_code)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CouponActivationState.waiting_for_coupon_code)
|
||||||
|
async def process_coupon_code(message: Message, state: FSMContext, session: Any):
|
||||||
|
coupon_code = message.text.strip()
|
||||||
|
await activate_coupon(message, state, session, coupon_code=coupon_code)
|
||||||
|
|
||||||
|
|
||||||
|
async def activate_coupon(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
coupon_code: str,
|
||||||
|
admin: bool = False,
|
||||||
|
user_data: dict | None = None,
|
||||||
|
):
|
||||||
|
logger.info(f"Активация купона: {coupon_code}")
|
||||||
|
coupon = await get_coupon_by_code(session, coupon_code)
|
||||||
|
|
||||||
|
if not coupon:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input")
|
||||||
|
)
|
||||||
|
await message.answer(COUPON_NOT_FOUND_MSG, reply_markup=builder.as_markup())
|
||||||
|
return
|
||||||
|
|
||||||
|
if coupon.usage_count >= coupon.usage_limit or coupon.is_used:
|
||||||
|
await message.answer("❌ Лимит активаций купона исчерпан.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
user = user_data or message.from_user or message.chat
|
||||||
|
user_id = user["tg_id"] if isinstance(user, dict) else user.id
|
||||||
|
|
||||||
|
usage = await check_coupon_usage(session, coupon.id, user_id)
|
||||||
|
if usage:
|
||||||
|
await message.answer(COUPON_ALREADY_USED_MSG)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
user_exists = await check_user_exists(session, user_id)
|
||||||
|
if not user_exists:
|
||||||
|
if isinstance(user, dict):
|
||||||
|
await add_user(session=session, **user)
|
||||||
|
else:
|
||||||
|
await add_user(
|
||||||
|
session=session,
|
||||||
|
tg_id=user.id,
|
||||||
|
username=getattr(user, "username", None),
|
||||||
|
first_name=getattr(user, "first_name", None),
|
||||||
|
last_name=getattr(user, "last_name", None),
|
||||||
|
language_code=getattr(user, "language_code", None),
|
||||||
|
is_bot=getattr(user, "is_bot", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if coupon.amount > 0:
|
||||||
|
try:
|
||||||
|
await update_balance(session, user_id, coupon.amount)
|
||||||
|
await update_coupon_usage_count(session, coupon.id)
|
||||||
|
await create_coupon_usage(session, coupon.id, user_id)
|
||||||
|
await add_payment(
|
||||||
|
session,
|
||||||
|
tg_id=user_id,
|
||||||
|
amount=coupon.amount,
|
||||||
|
payment_system="coupon"
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Купон активирован, на баланс начислено {coupon.amount} рублей."
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при активации купона на баланс: {e}")
|
||||||
|
await message.answer("❌ Ошибка при активации купона.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
if coupon.days:
|
||||||
|
try:
|
||||||
|
keys = await get_keys(session, user_id)
|
||||||
|
active_keys = [k for k in keys if not k.is_frozen]
|
||||||
|
|
||||||
|
if not active_keys:
|
||||||
|
await message.answer("❌ У вас нет активных подписок для продления.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||||
|
response_message = (
|
||||||
|
"<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in active_keys:
|
||||||
|
key_display = html.escape((key.alias or key.email).strip())
|
||||||
|
expiry_date = datetime.fromtimestamp(
|
||||||
|
key.expiry_time / 1000, tz=moscow_tz
|
||||||
|
).strftime("до %d.%m.%y, %H:%M")
|
||||||
|
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
|
||||||
|
builder.button(
|
||||||
|
text=key_display,
|
||||||
|
callback_data=f"extend_key|{key.client_id}|{coupon.id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
response_message += "</blockquote>"
|
||||||
|
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
|
||||||
|
builder.adjust(1)
|
||||||
|
|
||||||
|
await message.answer(response_message, reply_markup=builder.as_markup())
|
||||||
|
await state.set_state(CouponActivationState.waiting_for_key_selection)
|
||||||
|
await state.update_data(coupon_id=coupon.id, user_id=user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при обработке купона на дни: {e}")
|
||||||
|
await message.answer("❌ Ошибка при активации купона.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer("❌ Купон недействителен (нет суммы или дней).")
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("extend_key|"))
|
||||||
|
async def handle_key_extension(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
admin: bool = False,
|
||||||
|
):
|
||||||
|
from database.models import Coupon, Key
|
||||||
|
|
||||||
|
parts = callback_query.data.split("|")
|
||||||
|
client_id = parts[1]
|
||||||
|
coupon_id = int(parts[2])
|
||||||
|
tg_id = callback_query.from_user.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await session.execute(select(Coupon).where(Coupon.id == coupon_id))
|
||||||
|
coupon = result.scalar_one_or_none()
|
||||||
|
if not coupon or coupon.usage_count >= coupon.usage_limit:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"❌ Купон недействителен или лимит исчерпан."
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
usage = await check_coupon_usage(session, coupon.id, tg_id)
|
||||||
|
if usage:
|
||||||
|
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||||
|
)
|
||||||
|
key = result.scalar_one_or_none()
|
||||||
|
if not key or key.is_frozen:
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
"❌ Выбранная подписка не найдена или заморожена."
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
now_ms = int(datetime.now().timestamp() * 1000)
|
||||||
|
current_expiry = key.expiry_time
|
||||||
|
new_expiry = max(now_ms, current_expiry) + (coupon.days * 86400 * 1000)
|
||||||
|
|
||||||
|
tariff = None
|
||||||
|
if key.tariff_id:
|
||||||
|
tariff = await get_tariff_by_id(session, key.tariff_id)
|
||||||
|
total_gb = int(tariff["traffic_limit"]) if tariff and tariff.get("traffic_limit") else 0
|
||||||
|
device_limit = int(tariff["device_limit"]) if tariff and tariff.get("device_limit") else None
|
||||||
|
|
||||||
|
await renew_key_in_cluster(
|
||||||
|
cluster_id=key.server_id,
|
||||||
|
email=key.email,
|
||||||
|
client_id=client_id,
|
||||||
|
new_expiry_time=new_expiry,
|
||||||
|
total_gb=total_gb,
|
||||||
|
hwid_device_limit=device_limit,
|
||||||
|
session=session
|
||||||
|
)
|
||||||
|
await update_key_expiry(session, client_id, new_expiry)
|
||||||
|
await update_coupon_usage_count(session, coupon.id)
|
||||||
|
await create_coupon_usage(session, coupon.id, tg_id)
|
||||||
|
|
||||||
|
alias = key.alias or key.email
|
||||||
|
expiry_date = datetime.fromtimestamp(
|
||||||
|
new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")
|
||||||
|
).strftime("%d.%m.%y, %H:%M")
|
||||||
|
await callback_query.message.answer(
|
||||||
|
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon.days)}⏳ до {expiry_date}📆."
|
||||||
|
)
|
||||||
|
await process_callback_view_profile(
|
||||||
|
callback_query.message, state, admin, session
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при продлении ключа: {e}")
|
||||||
|
await callback_query.message.edit_text("❌ Ошибка при активации купона.")
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "cancel_coupon_activation")
|
||||||
|
async def cancel_coupon_activation(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
admin: bool = False,
|
||||||
|
session: AsyncSession = None,
|
||||||
|
):
|
||||||
|
await callback_query.message.edit_text("⚠️ Активация купона отменена.")
|
||||||
|
await process_callback_view_profile(callback_query.message, state, admin, session)
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "exit_coupon_input")
|
||||||
|
async def handle_exit_coupon_input(
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession = None,
|
||||||
|
):
|
||||||
|
await state.clear()
|
||||||
|
is_admin = callback_query.from_user.id in ADMIN_ID
|
||||||
|
await process_callback_view_profile(
|
||||||
|
callback_query.message, state, admin=is_admin, session=session
|
||||||
|
)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
InlineKeyboardButton,
|
||||||
|
LabeledPrice,
|
||||||
|
Message,
|
||||||
|
PreCheckoutQuery,
|
||||||
|
)
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from config import RUB_TO_XTR
|
||||||
|
from handlers.buttons import BACK, MAIN_MENU
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from .utils import edit_or_send_message
|
||||||
|
|
||||||
|
|
||||||
|
class DonateState(StatesGroup):
|
||||||
|
entering_donate_amount = State()
|
||||||
|
waiting_for_donate_confirmation = State()
|
||||||
|
waiting_for_donate_payment = State()
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "donate")
|
||||||
|
async def process_donate(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="💰 Ввести сумму доната",
|
||||||
|
callback_data="enter_custom_donate_amount",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"🌟 Поддержите наш проект! 💪\n\n"
|
||||||
|
"💖 Каждый донат помогает развивать и улучшать сервис. "
|
||||||
|
"🤝 Мы ценим вашу поддержку и работаем над тем, чтобы сделать наш продукт еще лучше. 🚀💡"
|
||||||
|
)
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=text,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "enter_custom_donate_amount")
|
||||||
|
async def process_enter_donate_amount(callback_query: CallbackQuery, state: FSMContext):
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
|
||||||
|
text = "💸 Введите сумму доната в рублях:"
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=text,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(DonateState.entering_donate_amount)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(DonateState.entering_donate_amount)
|
||||||
|
async def process_donate_amount_input(message: Message, state: FSMContext):
|
||||||
|
if message.text.isdigit():
|
||||||
|
amount = int(message.text)
|
||||||
|
if amount // RUB_TO_XTR <= 0:
|
||||||
|
await message.answer(
|
||||||
|
f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(amount=amount)
|
||||||
|
await state.set_state(DonateState.waiting_for_donate_confirmation)
|
||||||
|
|
||||||
|
try:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text="Задонатить", pay=True))
|
||||||
|
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
|
||||||
|
|
||||||
|
await message.answer_invoice(
|
||||||
|
title=f"Донат проекту {amount} рублей",
|
||||||
|
description="Спасибо за вашу поддержку!",
|
||||||
|
prices=[LabeledPrice(label="Донат", amount=int(amount // RUB_TO_XTR))],
|
||||||
|
provider_token="",
|
||||||
|
payload=f"{amount}_donate",
|
||||||
|
currency="XTR",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
await state.set_state(DonateState.waiting_for_donate_payment)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании доната: {e}")
|
||||||
|
else:
|
||||||
|
await message.answer("Некорректная сумма. Пожалуйста, введите сумму еще раз:")
|
||||||
|
|
||||||
|
|
||||||
|
@router.pre_checkout_query(DonateState.waiting_for_donate_payment)
|
||||||
|
async def on_pre_checkout_query(pre_checkout_query: PreCheckoutQuery):
|
||||||
|
await pre_checkout_query.answer(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.successful_payment, DonateState.waiting_for_donate_payment)
|
||||||
|
async def on_successful_donate(message: Message, state: FSMContext):
|
||||||
|
try:
|
||||||
|
amount = float(message.successful_payment.invoice_payload.split("_")[0])
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
await message.answer(
|
||||||
|
text=f"🙏 Спасибо за донат {amount} рублей! Ваша поддержка очень важна для нас. 💖",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Ошибка конвертации user_id или amount: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Произошла ошибка при обработке доната: {e}")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import InlineKeyboardButton, Message
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from config import SUPPORT_CHAT_URL
|
||||||
|
from handlers.buttons import MAIN_MENU, SUPPORT
|
||||||
|
from handlers.texts import FALLBACK_MESSAGE
|
||||||
|
|
||||||
|
fallback_router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@fallback_router.message(F.text)
|
||||||
|
async def handle_unhandled_messages(message: Message):
|
||||||
|
keyboard = InlineKeyboardBuilder()
|
||||||
|
keyboard.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||||
|
keyboard.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
FALLBACK_MESSAGE,
|
||||||
|
reply_markup=keyboard.as_markup(),
|
||||||
|
)
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from aiogram import types
|
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
||||||
|
|
||||||
|
|
||||||
async def send_instructions(callback_query: types.CallbackQuery):
|
|
||||||
instructions_message = (
|
|
||||||
"*📋 Инструкции по использованию вашего ключа:*\n\n"
|
|
||||||
"1. Скачайте приложение для вашего устройства:\n"
|
|
||||||
" - Для Android: [V2Ray](https://play.google.com/store/apps/details?id=com.v2ray.ang&hl=ru&pli=1)\n"
|
|
||||||
" - Для iPhone: [Streisand](https://apps.apple.com/ru/app/streisand/id6450534064)\n"
|
|
||||||
" - Для Windows: [Hiddify Next](https://github.com/hiddify/hiddify-next/releases/latest/download/Hiddify-Windows-Setup-x64.Msix)\n\n"
|
|
||||||
"2. Скопируйте предоставленный ключ, который вы получили ранее.\n"
|
|
||||||
"3. Откройте приложение и нажмите на плюсик сверху справа.\n"
|
|
||||||
"4. Выберите 'Вставить из буфера обмена' для добавления ключа.\n\n"
|
|
||||||
"💬 Если у вас возникнут вопросы, не стесняйтесь обращаться в поддержку."
|
|
||||||
)
|
|
||||||
|
|
||||||
back_button = InlineKeyboardButton(text='🔙 Назад', callback_data='back_to_main')
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
|
||||||
|
|
||||||
await callback_query.message.edit_text(instructions_message, parse_mode='Markdown', reply_markup=keyboard)
|
|
||||||
await callback_query.answer()
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
from .instructions import router as instructions_router
|
||||||
|
|
||||||
|
router = Router(name="instructions_main_router")
|
||||||
|
|
||||||
|
router.include_routers(
|
||||||
|
instructions_router,
|
||||||
|
)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
CONNECT_MACOS,
|
||||||
|
CONNECT_WINDOWS,
|
||||||
|
DOWNLOAD_MACOS,
|
||||||
|
DOWNLOAD_PC,
|
||||||
|
SUPPORT_CHAT_URL,
|
||||||
|
)
|
||||||
|
from database import get_key_details
|
||||||
|
from handlers.buttons import (
|
||||||
|
BACK,
|
||||||
|
CONNECT_MACOS_BUTTON,
|
||||||
|
CONNECT_WINDOWS_BUTTON,
|
||||||
|
DOWNLOAD_MACOS_BUTTON,
|
||||||
|
DOWNLOAD_PC_BUTTON,
|
||||||
|
MAIN_MENU,
|
||||||
|
PC_MACOS,
|
||||||
|
PC_PC,
|
||||||
|
SUPPORT,
|
||||||
|
TV_CONTINUE,
|
||||||
|
TV_INSTRUCTIONS,
|
||||||
|
)
|
||||||
|
from handlers.texts import (
|
||||||
|
CHOOSE_DEVICE_TEXT,
|
||||||
|
CONNECT_TV_TEXT,
|
||||||
|
INSTRUCTION_MACOS,
|
||||||
|
INSTRUCTION_PC,
|
||||||
|
INSTRUCTIONS,
|
||||||
|
KEY_MESSAGE,
|
||||||
|
SUBSCRIPTION_DETAILS_TEXT,
|
||||||
|
)
|
||||||
|
from handlers.utils import edit_or_send_message
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "instructions")
|
||||||
|
@router.message(F.text == "/instructions")
|
||||||
|
async def send_instructions(callback_query_or_message: CallbackQuery | Message):
|
||||||
|
instructions_message = INSTRUCTIONS
|
||||||
|
image_path = os.path.join("img", "instructions.jpg")
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
if isinstance(callback_query_or_message, CallbackQuery):
|
||||||
|
target_message = callback_query_or_message.message
|
||||||
|
else:
|
||||||
|
target_message = callback_query_or_message
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=target_message,
|
||||||
|
text=instructions_message,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=image_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("connect_pc|"))
|
||||||
|
async def process_connect_pc(callback_query: CallbackQuery, session: Any):
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
record = await get_key_details(session, key_name)
|
||||||
|
if not record:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text="❌ <b>Ключ не найден. Проверьте имя ключа.</b> 🔍",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}")
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{key_name}")
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=CHOOSE_DEVICE_TEXT,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("windows_menu|"))
|
||||||
|
async def process_windows_menu(callback_query: CallbackQuery, session: Any):
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
record = await get_key_details(session, key_name)
|
||||||
|
key = record["key"]
|
||||||
|
key_message_text = KEY_MESSAGE.format(key)
|
||||||
|
instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=DOWNLOAD_PC_BUTTON, url=DOWNLOAD_PC))
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{key}")
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||||
|
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=instruction_message,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("macos_menu|"))
|
||||||
|
async def process_macos_menu(callback_query: CallbackQuery, session: Any):
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
record = await get_key_details(session, key_name)
|
||||||
|
key = record["key"]
|
||||||
|
key_message_text = KEY_MESSAGE.format(key)
|
||||||
|
instruction_message = f"{key_message_text}{INSTRUCTION_MACOS}"
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=DOWNLOAD_MACOS_BUTTON, url=DOWNLOAD_MACOS))
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{key}")
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||||
|
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=instruction_message,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("connect_tv|"))
|
||||||
|
async def process_connect_tv(callback_query: CallbackQuery, session: Any):
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}")
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=CONNECT_TV_TEXT,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("continue_tv|"))
|
||||||
|
async def process_continue_tv(callback_query: CallbackQuery, session: Any):
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
record = await get_key_details(session, key_name)
|
||||||
|
subscription_link = record.get("key") or record.get("remnawave_link")
|
||||||
|
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_tv|{key_name}"))
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=message_text,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
from aiogram import Router, types
|
|
||||||
|
|
||||||
from auth import login_with_credentials
|
|
||||||
from bot import bot
|
|
||||||
from client import delete_client, extend_client_key
|
|
||||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL
|
|
||||||
from database import get_balance, update_balance
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
from aiogram import Router, types
|
|
||||||
|
|
||||||
from auth import login_with_credentials
|
|
||||||
from bot import bot
|
|
||||||
from client import delete_client, extend_client_key
|
|
||||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL
|
|
||||||
from database import get_balance, update_balance
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
# Обработка запроса на просмотр ключей
|
|
||||||
@router.callback_query(lambda c: c.data == 'view_keys')
|
|
||||||
async def process_callback_view_keys(callback_query: types.CallbackQuery):
|
|
||||||
tg_id = callback_query.from_user.id
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
try:
|
|
||||||
records = await conn.fetch('''
|
|
||||||
SELECT email, client_id FROM keys WHERE tg_id = $1
|
|
||||||
''', tg_id)
|
|
||||||
|
|
||||||
if records:
|
|
||||||
# Создаем кнопки для каждого ключа
|
|
||||||
buttons = []
|
|
||||||
for record in records:
|
|
||||||
key_name = record['email']
|
|
||||||
client_id = record['client_id']
|
|
||||||
# Заменяем подчеркивание на вертикальную черту в callback_data
|
|
||||||
button = types.InlineKeyboardButton(text=f"🔑 {key_name}", callback_data=f'view_key|{key_name}|{client_id}')
|
|
||||||
buttons.append([button])
|
|
||||||
|
|
||||||
# Создаем клавиатуру с кнопками
|
|
||||||
inline_keyboard = types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
|
||||||
response_message = "<b>Выберите ключ для просмотра информации:</b>"
|
|
||||||
|
|
||||||
# Редактируем сообщение с клавиатурой
|
|
||||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=inline_keyboard, parse_mode="HTML")
|
|
||||||
else:
|
|
||||||
# Если нет ключей, добавляем кнопку "Создать ключ" и "Назад"
|
|
||||||
response_message = "<b>У вас нет ключей.</b>"
|
|
||||||
|
|
||||||
# Кнопка "Создать ключ"
|
|
||||||
create_key_button = types.InlineKeyboardButton(text='➕ Создать ключ', callback_data='create_key')
|
|
||||||
back_button = types.InlineKeyboardButton(text='🔙 Назад', callback_data='view_profile') # Измените на правильное значение для кнопки "Назад"
|
|
||||||
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[create_key_button], [back_button]])
|
|
||||||
|
|
||||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard, parse_mode="HTML")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await handle_error(tg_id, callback_query, f"Ошибка при получении ключей: {e}")
|
|
||||||
|
|
||||||
await callback_query.answer()
|
|
||||||
|
|
||||||
@router.callback_query(lambda c: c.data.startswith('view_key|'))
|
|
||||||
async def process_callback_view_key(callback_query: types.CallbackQuery):
|
|
||||||
tg_id = callback_query.from_user.id
|
|
||||||
# Разделяем данные по вертикальной черте
|
|
||||||
key_name, client_id = callback_query.data.split('|')[1], callback_query.data.split('|')[2]
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
try:
|
|
||||||
record = await conn.fetchrow('''
|
|
||||||
SELECT k.key, k.expiry_time
|
|
||||||
FROM keys k
|
|
||||||
WHERE k.tg_id = $1 AND k.email = $2
|
|
||||||
''', tg_id, key_name)
|
|
||||||
|
|
||||||
if record:
|
|
||||||
key = record['key']
|
|
||||||
expiry_time = record['expiry_time']
|
|
||||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
|
||||||
current_date = datetime.utcnow()
|
|
||||||
time_left = expiry_date - current_date
|
|
||||||
|
|
||||||
if time_left.total_seconds() <= 0:
|
|
||||||
days_left_message = "<b>Ключ истек.</b>"
|
|
||||||
elif time_left.days > 0:
|
|
||||||
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
|
|
||||||
else:
|
|
||||||
hours_left = time_left.seconds // 3600
|
|
||||||
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
|
|
||||||
|
|
||||||
response_message = (f"🔑 <b>Ваш ключ:</b>\n<pre>{key}</pre>\n"
|
|
||||||
f"📅 <b>Дата окончания:</b> {expiry_date.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
||||||
f"{days_left_message}")
|
|
||||||
|
|
||||||
# Кнопки для продления, инструкций и удаления
|
|
||||||
renew_button = types.InlineKeyboardButton(text='⏳ Продлить ключ', callback_data=f'renew_key|{client_id}')
|
|
||||||
instructions_button = types.InlineKeyboardButton(text='📘 Инструкции', callback_data='instructions')
|
|
||||||
delete_button = types.InlineKeyboardButton(text='❌ Удалить ключ', callback_data=f'delete_key|{client_id}')
|
|
||||||
back_button = types.InlineKeyboardButton(text='🔙 Назад в профиль', callback_data='view_profile')
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[renew_button], [instructions_button], [delete_button], [back_button]])
|
|
||||||
|
|
||||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard, parse_mode="HTML")
|
|
||||||
else:
|
|
||||||
await bot.edit_message_text("<b>Информация о ключе не найдена.</b>", chat_id=tg_id, message_id=callback_query.message.message_id, parse_mode="HTML")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await handle_error(tg_id, callback_query, f"Ошибка при получении информации о ключе: {e}")
|
|
||||||
|
|
||||||
await callback_query.answer()
|
|
||||||
|
|
||||||
# Обработка запроса на удаление ключа
|
|
||||||
@router.callback_query(lambda c: c.data.startswith('delete_key|'))
|
|
||||||
async def process_callback_delete_key(callback_query: types.CallbackQuery):
|
|
||||||
tg_id = callback_query.from_user.id
|
|
||||||
client_id = callback_query.data.split('|')[1] # Используем разделитель вертикальная черта
|
|
||||||
|
|
||||||
confirmation_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[types.InlineKeyboardButton(text='✅ Да, удалить', callback_data=f'confirm_delete|{client_id}')],
|
|
||||||
[types.InlineKeyboardButton(text='❌ Нет, отменить', callback_data='view_keys')]
|
|
||||||
])
|
|
||||||
|
|
||||||
await bot.edit_message_text("<b>Вы уверены, что хотите удалить ключ?</b>", chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=confirmation_keyboard, parse_mode="HTML")
|
|
||||||
await callback_query.answer()
|
|
||||||
|
|
||||||
|
|
||||||
# Обработка выбора плана продления
|
|
||||||
@router.callback_query(lambda c: c.data.startswith('renew_key|'))
|
|
||||||
async def process_callback_renew_key(callback_query: types.CallbackQuery):
|
|
||||||
tg_id = callback_query.from_user.id
|
|
||||||
client_id = callback_query.data.split('|')[1] # Используем разделитель вертикальная черта
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
try:
|
|
||||||
record = await conn.fetchrow('SELECT email, expiry_time FROM keys WHERE client_id = $1', client_id)
|
|
||||||
|
|
||||||
if record:
|
|
||||||
email = record['email']
|
|
||||||
expiry_time = record['expiry_time']
|
|
||||||
current_time = datetime.utcnow().timestamp() * 1000 # Получаем текущее время в миллисекундах
|
|
||||||
|
|
||||||
# Добавляем новые планы продления (6 месяцев и год)
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[types.InlineKeyboardButton(text='📅 1 месяц (100 руб.)', callback_data=f'renew_plan|1|{client_id}')],
|
|
||||||
[types.InlineKeyboardButton(text='📅 3 месяца (285 руб.)', callback_data=f'renew_plan|3|{client_id}')],
|
|
||||||
[types.InlineKeyboardButton(text='📅 6 месяцев (540 руб.)', callback_data=f'renew_plan|6|{client_id}')],
|
|
||||||
[types.InlineKeyboardButton(text='📅 12 месяцев (1000 руб.)', callback_data=f'renew_plan|12|{client_id}')],
|
|
||||||
[types.InlineKeyboardButton(text='🔙 Назад', callback_data='view_profile')]
|
|
||||||
])
|
|
||||||
|
|
||||||
balance = await get_balance(tg_id)
|
|
||||||
response_message = (f"<b>Выберите план продления:</b>\n"
|
|
||||||
f"💰 <b>Баланс:</b> {balance} руб.\n"
|
|
||||||
f"📅 <b>Текущая дата истечения ключа:</b> {datetime.utcfromtimestamp(expiry_time / 1000).strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
|
|
||||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard, parse_mode="HTML")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await bot.edit_message_text(f"<b>Ошибка при выборе плана:</b> {e}", chat_id=tg_id, message_id=callback_query.message.message_id, parse_mode="HTML")
|
|
||||||
|
|
||||||
await callback_query.answer()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(lambda c: c.data.startswith('confirm_delete|'))
|
|
||||||
async def process_callback_confirm_delete(callback_query: types.CallbackQuery):
|
|
||||||
tg_id = callback_query.from_user.id
|
|
||||||
client_id = callback_query.data.split('|')[1] # Используем разделитель вертикальная черта
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
try:
|
|
||||||
record = await conn.fetchrow('SELECT email FROM keys WHERE client_id = $1', client_id)
|
|
||||||
|
|
||||||
if record:
|
|
||||||
email = record['email']
|
|
||||||
|
|
||||||
session = login_with_credentials(ADMIN_USERNAME, ADMIN_PASSWORD)
|
|
||||||
success = delete_client(session, client_id)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
await conn.execute('DELETE FROM keys WHERE client_id = $1', client_id)
|
|
||||||
response_message = "Ключ был успешно удален."
|
|
||||||
else:
|
|
||||||
response_message = "Ошибка при удалении клиента через API."
|
|
||||||
|
|
||||||
else:
|
|
||||||
response_message = "Ключ не найден или уже удален."
|
|
||||||
|
|
||||||
back_button = types.InlineKeyboardButton(text='Назад', callback_data='view_keys')
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
|
||||||
|
|
||||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await bot.edit_message_text(f"Ошибка при удалении ключа: {e}", chat_id=tg_id, message_id=callback_query.message.message_id)
|
|
||||||
|
|
||||||
await callback_query.answer()
|
|
||||||
|
|
||||||
@router.callback_query(lambda c: c.data.startswith('renew_plan|'))
|
|
||||||
async def process_callback_renew_plan(callback_query: types.CallbackQuery):
|
|
||||||
tg_id = callback_query.from_user.id
|
|
||||||
plan, client_id = callback_query.data.split('|')[1], callback_query.data.split('|')[2] # '1', '3', '6' или '12' и client_id
|
|
||||||
days_to_extend = 30 * int(plan) # Умножаем на количество месяцев (1 месяц = 30 дней)
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = await asyncpg.connect(DATABASE_URL)
|
|
||||||
try:
|
|
||||||
record = await conn.fetchrow('SELECT email, expiry_time FROM keys WHERE client_id = $1', client_id)
|
|
||||||
|
|
||||||
if record:
|
|
||||||
email = record['email']
|
|
||||||
expiry_time = record['expiry_time']
|
|
||||||
current_time = datetime.utcnow().timestamp() * 1000 # Текущее время в миллисекундах
|
|
||||||
|
|
||||||
# Проверяем, если ключ истек, то продлеваем от текущей даты, иначе продлеваем от текущей даты истечения
|
|
||||||
if expiry_time <= current_time:
|
|
||||||
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
|
|
||||||
else:
|
|
||||||
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
|
|
||||||
|
|
||||||
# Стоимость продления в зависимости от выбранного плана
|
|
||||||
if plan == '1':
|
|
||||||
cost = 100
|
|
||||||
elif plan == '3':
|
|
||||||
cost = 285
|
|
||||||
elif plan == '6':
|
|
||||||
cost = 540 # Стоимость для 6 месяцев
|
|
||||||
elif plan == '12':
|
|
||||||
cost = 1000 # Стоимость для 12 месяцев
|
|
||||||
|
|
||||||
balance = await get_balance(tg_id)
|
|
||||||
if balance < cost:
|
|
||||||
replenish_button = types.InlineKeyboardButton(text='Пополнить баланс', callback_data='replenish_balance')
|
|
||||||
back_button = types.InlineKeyboardButton(text='Назад', callback_data='view_profile')
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[replenish_button], [back_button]])
|
|
||||||
|
|
||||||
await bot.edit_message_text("Недостаточно средств для продления ключа.", chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Продлеваем ключ через API
|
|
||||||
session = login_with_credentials(ADMIN_USERNAME, ADMIN_PASSWORD)
|
|
||||||
success = extend_client_key(session, tg_id, client_id, email, new_expiry_time)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
# Обновляем баланс и ключ в базе данных
|
|
||||||
await update_balance(tg_id, -cost)
|
|
||||||
await conn.execute('UPDATE keys SET expiry_time = $1 WHERE client_id = $2', new_expiry_time, client_id)
|
|
||||||
response_message = f"Ваш ключ был успешно продлен на {days_to_extend // 30} месяц(-а)."
|
|
||||||
back_button = types.InlineKeyboardButton(text='Назад', callback_data='view_profile')
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
|
||||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard)
|
|
||||||
else:
|
|
||||||
await bot.edit_message_text("Ошибка при продлении ключа.", chat_id=tg_id, message_id=callback_query.message.message_id)
|
|
||||||
else:
|
|
||||||
await bot.edit_message_text("Ключ не найден.", chat_id=tg_id, message_id=callback_query.message.message_id)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
await conn.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await bot.edit_message_text(f"Ошибка при продлении ключа: {e}", chat_id=tg_id, message_id=callback_query.message.message_id)
|
|
||||||
|
|
||||||
await callback_query.answer()
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_error(tg_id, callback_query, message):
|
|
||||||
await bot.edit_message_text(message, chat_id=tg_id, message_id=callback_query.message.message_id)
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
from .key_connect import router as connect_router
|
||||||
|
from .key_freeze import router as freeze_router
|
||||||
|
from .key_mode import router as key_mode_router
|
||||||
|
from .key_renew import router as renew_router
|
||||||
|
from .key_view import router as view_router
|
||||||
|
from .keys import router as keys_router
|
||||||
|
|
||||||
|
router = Router(name="keys_main_router")
|
||||||
|
|
||||||
|
router.include_routers(
|
||||||
|
keys_router,
|
||||||
|
view_router,
|
||||||
|
renew_router,
|
||||||
|
freeze_router,
|
||||||
|
connect_router,
|
||||||
|
key_mode_router,
|
||||||
|
)
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import os
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
from aiogram import F, Router, types
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
CONNECT_ANDROID,
|
||||||
|
CONNECT_IOS,
|
||||||
|
DOWNLOAD_ANDROID,
|
||||||
|
DOWNLOAD_IOS,
|
||||||
|
INSTRUCTIONS_BUTTON,
|
||||||
|
)
|
||||||
|
from database.models import Key
|
||||||
|
from handlers.buttons import (
|
||||||
|
ANDROID,
|
||||||
|
BACK,
|
||||||
|
DOWNLOAD_ANDROID_BUTTON,
|
||||||
|
DOWNLOAD_IOS_BUTTON,
|
||||||
|
IMPORT_ANDROID,
|
||||||
|
IMPORT_IOS,
|
||||||
|
IPHONE,
|
||||||
|
MAIN_MENU,
|
||||||
|
MANUAL_INSTRUCTIONS,
|
||||||
|
PC,
|
||||||
|
TV,
|
||||||
|
)
|
||||||
|
from handlers.texts import (
|
||||||
|
ANDROID_DESCRIPTION_TEMPLATE,
|
||||||
|
CHOOSE_DEVICE_TEXT,
|
||||||
|
IOS_DESCRIPTION_TEMPLATE,
|
||||||
|
SUBSCRIPTION_DESCRIPTION,
|
||||||
|
)
|
||||||
|
from handlers.utils import edit_or_send_message
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("connect_device|"))
|
||||||
|
async def handle_connect_device(callback_query: CallbackQuery):
|
||||||
|
try:
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}")
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=ANDROID, callback_data=f"connect_android|{key_name}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}")
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}")
|
||||||
|
)
|
||||||
|
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
|
||||||
|
)
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=CHOOSE_DEVICE_TEXT,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
|
||||||
|
logger.error(f"Ошибка в handle_connect_device: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("connect_phone|"))
|
||||||
|
async def process_callback_connect_phone(
|
||||||
|
callback_query: CallbackQuery, session: AsyncSession
|
||||||
|
):
|
||||||
|
email = callback_query.data.split("|")[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await session.execute(select(Key.key).where(Key.email == email))
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
key_link = row
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении ключа для {email}: {e}")
|
||||||
|
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||||
|
return
|
||||||
|
|
||||||
|
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
||||||
|
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
|
||||||
|
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
|
||||||
|
)
|
||||||
|
if INSTRUCTIONS_BUTTON:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=description,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("connect_ios|"))
|
||||||
|
async def process_callback_connect_ios(
|
||||||
|
callback_query: CallbackQuery, session: AsyncSession
|
||||||
|
):
|
||||||
|
email = callback_query.data.split("|")[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await session.execute(select(Key.key).where(Key.email == email))
|
||||||
|
key_link = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not key_link:
|
||||||
|
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
|
||||||
|
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||||
|
return
|
||||||
|
|
||||||
|
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
|
||||||
|
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
|
||||||
|
if INSTRUCTIONS_BUTTON:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=description,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("connect_android|"))
|
||||||
|
async def process_callback_connect_android(
|
||||||
|
callback_query: CallbackQuery, session: AsyncSession
|
||||||
|
):
|
||||||
|
email = callback_query.data.split("|")[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await session.execute(select(Key.key).where(Key.email == email))
|
||||||
|
key_link = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not key_link:
|
||||||
|
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
|
||||||
|
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||||
|
return
|
||||||
|
|
||||||
|
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}")
|
||||||
|
)
|
||||||
|
if INSTRUCTIONS_BUTTON:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=description,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("show_qr|"))
|
||||||
|
async def show_qr_code(callback_query: types.CallbackQuery, session: AsyncSession):
|
||||||
|
try:
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
|
||||||
|
stmt = select(Key).where(Key.email == key_name)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
record = result.scalars().first()
|
||||||
|
|
||||||
|
if not record:
|
||||||
|
await callback_query.message.answer("❌ Подписка не найдена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
qr_data = record.key or record.remnawave_link
|
||||||
|
if not qr_data:
|
||||||
|
await callback_query.message.answer(
|
||||||
|
"❌ У этой подписки отсутствует ссылка для подключения."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||||
|
qr.add_data(qr_data)
|
||||||
|
qr.make(fit=True)
|
||||||
|
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
buffer = BytesIO()
|
||||||
|
img.save(buffer, format="PNG")
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
qr_path = f"/tmp/qrcode_{record.email}.png"
|
||||||
|
with open(qr_path, "wb") as f:
|
||||||
|
f.write(buffer.read())
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record.email}")
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text="🔲 <b>Ваш QR-код для подключения</b>",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=qr_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
os.remove(qr_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
|
||||||
|
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from database import get_key_details, mark_key_as_frozen, mark_key_as_unfrozen
|
||||||
|
from handlers.buttons import APPLY, BACK, CANCEL
|
||||||
|
from handlers.keys.key_utils import renew_key_in_cluster, toggle_client_on_cluster
|
||||||
|
from handlers.texts import (
|
||||||
|
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
|
||||||
|
SUBSCRIPTION_FROZEN_MSG,
|
||||||
|
SUBSCRIPTION_UNFROZEN_MSG,
|
||||||
|
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
|
||||||
|
)
|
||||||
|
from handlers.utils import edit_or_send_message, handle_error
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
|
||||||
|
async def process_callback_unfreeze_subscription(
|
||||||
|
callback_query: CallbackQuery, session: Any
|
||||||
|
):
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=APPLY,
|
||||||
|
callback_data=f"unfreeze_subscription_confirm|{key_name}",
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=CANCEL,
|
||||||
|
callback_data=f"view_key|{key_name}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=confirm_text,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
|
||||||
|
async def process_callback_unfreeze_subscription_confirm(
|
||||||
|
callback_query: CallbackQuery, session: Any
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Размораживает (включает) подписку.
|
||||||
|
"""
|
||||||
|
tg_id = callback_query.message.chat.id
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = await get_key_details(session, key_name)
|
||||||
|
if not record:
|
||||||
|
await callback_query.message.answer("Ключ не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
email = record["email"]
|
||||||
|
client_id = record["client_id"]
|
||||||
|
cluster_id = record["server_id"]
|
||||||
|
|
||||||
|
result = await toggle_client_on_cluster(
|
||||||
|
cluster_id, email, client_id, enable=True, session=session
|
||||||
|
)
|
||||||
|
if result["status"] != "success":
|
||||||
|
text_error = f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
|
||||||
|
)
|
||||||
|
await edit_or_send_message(
|
||||||
|
callback_query.message, text_error, builder.as_markup()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
leftover = record["expiry_time"]
|
||||||
|
logger.info(f"[Unfreeze Debug] expiry_time из БД: {leftover}")
|
||||||
|
if leftover < 0:
|
||||||
|
leftover = 0
|
||||||
|
new_expiry_time = now_ms + leftover
|
||||||
|
|
||||||
|
await mark_key_as_unfrozen(session, record["tg_id"], client_id, new_expiry_time)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
from database.servers import get_servers
|
||||||
|
|
||||||
|
servers = await get_servers(session)
|
||||||
|
cluster_servers = servers.get(cluster_id, [])
|
||||||
|
|
||||||
|
tariff = None
|
||||||
|
for srv in cluster_servers:
|
||||||
|
result = await session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT t.*
|
||||||
|
FROM tariffs t
|
||||||
|
JOIN servers s ON s.tariff_group = t.group_code
|
||||||
|
WHERE s.server_name = :server_name
|
||||||
|
ORDER BY t.duration_days DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"server_name": srv["server_name"]},
|
||||||
|
)
|
||||||
|
row = result.mappings().first()
|
||||||
|
if row:
|
||||||
|
tariff = row
|
||||||
|
break
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
if not tariff:
|
||||||
|
logger.info(
|
||||||
|
"[Unfreeze] Тариф не найден — возможно ключ триальный. Применяем дефолтные значения."
|
||||||
|
)
|
||||||
|
base_bytes = 15 * 1024
|
||||||
|
hwid_limit = 1
|
||||||
|
else:
|
||||||
|
base_bytes = int(tariff.get("traffic_limit") or 0)
|
||||||
|
hwid_limit = int(tariff.get("device_limit") or 0)
|
||||||
|
|
||||||
|
added_days = max(leftover / (1000 * 86400), 0.01)
|
||||||
|
total_gb = int((added_days / 30) * base_bytes)
|
||||||
|
logger.info(
|
||||||
|
f"[Unfreeze Debug] Запуск renew_key_in_cluster с expiry={new_expiry_time}, gb={total_gb}, hwid={hwid_limit}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await renew_key_in_cluster(
|
||||||
|
cluster_id=cluster_id,
|
||||||
|
email=email,
|
||||||
|
client_id=client_id,
|
||||||
|
new_expiry_time=new_expiry_time,
|
||||||
|
total_gb=total_gb,
|
||||||
|
session=session,
|
||||||
|
hwid_device_limit=hwid_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
text_ok = SUBSCRIPTION_UNFROZEN_MSG
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
|
||||||
|
)
|
||||||
|
await edit_or_send_message(callback_query.message, text_ok, builder.as_markup())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("freeze_subscription|"))
|
||||||
|
async def process_callback_freeze_subscription(
|
||||||
|
callback_query: CallbackQuery, session: Any
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
|
||||||
|
"""
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
|
||||||
|
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=APPLY,
|
||||||
|
callback_data=f"freeze_subscription_confirm|{key_name}",
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=CANCEL,
|
||||||
|
callback_data=f"view_key|{key_name}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=confirm_text,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
|
||||||
|
async def process_callback_freeze_subscription_confirm(
|
||||||
|
callback_query: CallbackQuery, session: Any
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Замораживает (отключает) подписку.
|
||||||
|
"""
|
||||||
|
tg_id = callback_query.message.chat.id
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = await get_key_details(session, key_name)
|
||||||
|
if not record:
|
||||||
|
await callback_query.message.answer("Ключ не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
email = record["email"]
|
||||||
|
client_id = record["client_id"]
|
||||||
|
cluster_id = record["server_id"]
|
||||||
|
|
||||||
|
result = await toggle_client_on_cluster(
|
||||||
|
cluster_id, email, client_id, enable=False, session=session
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["status"] == "success":
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
time_left = record["expiry_time"] - now_ms
|
||||||
|
if time_left < 0:
|
||||||
|
time_left = 0
|
||||||
|
|
||||||
|
await mark_key_as_frozen(session, record["tg_id"], client_id, time_left)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
text_ok = SUBSCRIPTION_FROZEN_MSG
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
|
||||||
|
)
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=text_ok,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
text_error = f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
|
||||||
|
)
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=text_error,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
__all__ = ("router",)
|
||||||
|
|
||||||
|
from aiogram import Router
|
||||||
|
|
||||||
|
from .key_cluster_mode import router as cluster_router
|
||||||
|
from .key_country_mode import router as country_router
|
||||||
|
from .key_create import router as create_router
|
||||||
|
from .key_discount_mode import router as discount_router
|
||||||
|
|
||||||
|
router = Router(name="key_mode_router")
|
||||||
|
|
||||||
|
router.include_routers(create_router, cluster_router, country_router, discount_router)
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
FSInputFile,
|
||||||
|
InlineKeyboardButton,
|
||||||
|
Message,
|
||||||
|
WebAppInfo,
|
||||||
|
)
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from bot import bot
|
||||||
|
from config import CONNECT_PHONE_BUTTON, SUPPORT_CHAT_URL, TRIAL_CONFIG
|
||||||
|
from database import (
|
||||||
|
get_key_details,
|
||||||
|
get_tariff_by_id,
|
||||||
|
get_trial,
|
||||||
|
update_balance,
|
||||||
|
update_trial,
|
||||||
|
)
|
||||||
|
from handlers.buttons import (
|
||||||
|
CONNECT_DEVICE,
|
||||||
|
CONNECT_PHONE,
|
||||||
|
MAIN_MENU,
|
||||||
|
MY_SUB,
|
||||||
|
PC_BUTTON,
|
||||||
|
SUPPORT,
|
||||||
|
TV_BUTTON,
|
||||||
|
)
|
||||||
|
from handlers.keys.key_utils import create_key_on_cluster
|
||||||
|
from handlers.texts import key_message_success
|
||||||
|
from handlers.utils import (
|
||||||
|
edit_or_send_message,
|
||||||
|
generate_random_email,
|
||||||
|
get_least_loaded_cluster,
|
||||||
|
is_full_remnawave_cluster,
|
||||||
|
format_days,
|
||||||
|
format_months,
|
||||||
|
)
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
|
||||||
|
async def key_cluster_mode(
|
||||||
|
tg_id: int,
|
||||||
|
expiry_time: datetime,
|
||||||
|
state,
|
||||||
|
session,
|
||||||
|
message_or_query: Message | CallbackQuery | None = None,
|
||||||
|
plan: int = None,
|
||||||
|
):
|
||||||
|
target_message = None
|
||||||
|
safe_to_edit = False
|
||||||
|
|
||||||
|
if isinstance(message_or_query, CallbackQuery) and message_or_query.message:
|
||||||
|
target_message = message_or_query.message
|
||||||
|
safe_to_edit = True
|
||||||
|
elif isinstance(message_or_query, Message):
|
||||||
|
target_message = message_or_query
|
||||||
|
safe_to_edit = True
|
||||||
|
|
||||||
|
while True:
|
||||||
|
key_name = generate_random_email()
|
||||||
|
existing_key = await get_key_details(session, key_name)
|
||||||
|
if not existing_key:
|
||||||
|
break
|
||||||
|
|
||||||
|
client_id = str(uuid.uuid4())
|
||||||
|
email = key_name.lower()
|
||||||
|
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await state.get_data() if state else {}
|
||||||
|
is_trial = data.get("is_trial", False)
|
||||||
|
|
||||||
|
device_limit = 0
|
||||||
|
traffic_limit_gb = None
|
||||||
|
|
||||||
|
if is_trial:
|
||||||
|
device_limit = TRIAL_CONFIG.get("hwid_limit", 1)
|
||||||
|
traffic_limit_gb = TRIAL_CONFIG.get("traffic_limit_gb", 100)
|
||||||
|
elif plan:
|
||||||
|
tariff = await get_tariff_by_id(session, plan)
|
||||||
|
if tariff:
|
||||||
|
if tariff.get("device_limit") is not None:
|
||||||
|
device_limit = int(tariff["device_limit"])
|
||||||
|
if tariff.get("traffic_limit") is not None:
|
||||||
|
traffic_limit_gb = int(tariff["traffic_limit"])
|
||||||
|
|
||||||
|
least_loaded_cluster = await get_least_loaded_cluster(session)
|
||||||
|
await create_key_on_cluster(
|
||||||
|
cluster_id=least_loaded_cluster,
|
||||||
|
tg_id=tg_id,
|
||||||
|
client_id=client_id,
|
||||||
|
email=email,
|
||||||
|
expiry_timestamp=expiry_timestamp,
|
||||||
|
plan=plan,
|
||||||
|
session=session,
|
||||||
|
hwid_limit=device_limit,
|
||||||
|
traffic_limit_bytes=traffic_limit_gb,
|
||||||
|
is_trial=is_trial,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
key_record = await get_key_details(session, email)
|
||||||
|
if not key_record:
|
||||||
|
raise ValueError(f"Ключ не найден после создания: {email}")
|
||||||
|
|
||||||
|
public_link = key_record.get("key")
|
||||||
|
remnawave_link = key_record.get("remnawave_link")
|
||||||
|
final_link = public_link or remnawave_link or ""
|
||||||
|
|
||||||
|
if is_trial:
|
||||||
|
trial_status = await get_trial(session, tg_id)
|
||||||
|
if trial_status in [0, -1]:
|
||||||
|
await update_trial(session, tg_id, 1)
|
||||||
|
|
||||||
|
if data.get("tariff_id"):
|
||||||
|
tariff = await get_tariff_by_id(session, data["tariff_id"])
|
||||||
|
if tariff:
|
||||||
|
await update_balance(session, tg_id, -tariff["price_rub"])
|
||||||
|
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||||
|
error_message = (
|
||||||
|
"❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
|
||||||
|
)
|
||||||
|
|
||||||
|
if safe_to_edit:
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=target_message,
|
||||||
|
text=error_message,
|
||||||
|
reply_markup=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await bot.send_message(chat_id=tg_id, text=error_message)
|
||||||
|
return
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
if await is_full_remnawave_cluster(least_loaded_cluster, session):
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")
|
||||||
|
)
|
||||||
|
elif CONNECT_PHONE_BUTTON:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||||
|
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
|
||||||
|
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
expiry_time_local = expiry_time.astimezone(moscow_tz)
|
||||||
|
remaining_time = expiry_time_local - datetime.now(moscow_tz)
|
||||||
|
days = remaining_time.days
|
||||||
|
|
||||||
|
tariff_info = None
|
||||||
|
if plan:
|
||||||
|
tariff_info = await get_tariff_by_id(session, plan)
|
||||||
|
|
||||||
|
if is_trial:
|
||||||
|
trial_days = TRIAL_CONFIG.get("duration_days", 1)
|
||||||
|
if trial_days >= 30:
|
||||||
|
months = trial_days // 30
|
||||||
|
tariff_duration = format_months(months)
|
||||||
|
else:
|
||||||
|
tariff_duration = format_days(trial_days)
|
||||||
|
key_message_text = key_message_success(
|
||||||
|
final_link,
|
||||||
|
tariff_name=tariff_duration,
|
||||||
|
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
|
||||||
|
device_limit=TRIAL_CONFIG.get("hwid_limit", 1)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tariff_duration = ""
|
||||||
|
if tariff_info and tariff_info.get("duration_days", 0) > 0:
|
||||||
|
duration_days = tariff_info["duration_days"]
|
||||||
|
if duration_days >= 30:
|
||||||
|
months = duration_days // 30
|
||||||
|
tariff_duration = format_months(months)
|
||||||
|
else:
|
||||||
|
tariff_duration = format_days(duration_days)
|
||||||
|
|
||||||
|
key_message_text = key_message_success(
|
||||||
|
final_link,
|
||||||
|
tariff_name=tariff_duration,
|
||||||
|
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
|
||||||
|
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
default_media_path = "img/pic.jpg"
|
||||||
|
if safe_to_edit:
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=target_message,
|
||||||
|
text=key_message_text,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=default_media_path,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
photo = FSInputFile(default_media_path)
|
||||||
|
await bot.send_photo(
|
||||||
|
chat_id=tg_id,
|
||||||
|
photo=photo,
|
||||||
|
caption=key_message_text,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if state:
|
||||||
|
await state.clear()
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from py3xui import AsyncApi
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot import bot
|
||||||
|
from config import (
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
ADMIN_USERNAME,
|
||||||
|
CONNECT_PHONE_BUTTON,
|
||||||
|
PUBLIC_LINK,
|
||||||
|
REMNAWAVE_LOGIN,
|
||||||
|
REMNAWAVE_PASSWORD,
|
||||||
|
SUPPORT_CHAT_URL,
|
||||||
|
TRIAL_CONFIG,
|
||||||
|
)
|
||||||
|
from database import (
|
||||||
|
add_user,
|
||||||
|
check_server_name_by_cluster,
|
||||||
|
check_user_exists,
|
||||||
|
get_key_details,
|
||||||
|
get_trial,
|
||||||
|
update_balance,
|
||||||
|
update_trial,
|
||||||
|
)
|
||||||
|
from database.models import Key, Server, Tariff
|
||||||
|
from handlers.buttons import (
|
||||||
|
BACK,
|
||||||
|
CONNECT_DEVICE,
|
||||||
|
CONNECT_PHONE,
|
||||||
|
MAIN_MENU,
|
||||||
|
PC_BUTTON,
|
||||||
|
SUPPORT,
|
||||||
|
TV_BUTTON,
|
||||||
|
MY_SUB
|
||||||
|
)
|
||||||
|
from handlers.keys.key_utils import create_client_on_server
|
||||||
|
from handlers.texts import SELECT_COUNTRY_MSG, key_message_success
|
||||||
|
from handlers.utils import (
|
||||||
|
edit_or_send_message,
|
||||||
|
generate_random_email,
|
||||||
|
get_least_loaded_cluster,
|
||||||
|
is_full_remnawave_cluster,
|
||||||
|
format_days,
|
||||||
|
format_months,
|
||||||
|
)
|
||||||
|
from logger import logger
|
||||||
|
from panels.remnawave import RemnawaveAPI
|
||||||
|
from panels.three_xui import delete_client, get_xui_instance
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
|
||||||
|
async def key_country_mode(
|
||||||
|
tg_id: int,
|
||||||
|
expiry_time: datetime,
|
||||||
|
state: FSMContext,
|
||||||
|
session: AsyncSession,
|
||||||
|
message_or_query: Message | CallbackQuery | None = None,
|
||||||
|
old_key_name: str = None,
|
||||||
|
plan: int = None,
|
||||||
|
):
|
||||||
|
target_message = None
|
||||||
|
safe_to_edit = False
|
||||||
|
|
||||||
|
if state and plan:
|
||||||
|
await state.update_data(tariff_id=plan)
|
||||||
|
|
||||||
|
if isinstance(message_or_query, CallbackQuery) and message_or_query.message:
|
||||||
|
target_message = message_or_query.message
|
||||||
|
safe_to_edit = True
|
||||||
|
elif isinstance(message_or_query, Message):
|
||||||
|
target_message = message_or_query
|
||||||
|
safe_to_edit = True
|
||||||
|
|
||||||
|
least_loaded_cluster = await get_least_loaded_cluster(session)
|
||||||
|
if not least_loaded_cluster:
|
||||||
|
logger.error("❌ Не удалось определить наименее загруженный кластер")
|
||||||
|
text = "❌ Нет доступных кластеров для создания ключа."
|
||||||
|
if safe_to_edit:
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=target_message, text=text, reply_markup=None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await bot.send_message(chat_id=tg_id, text=text)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Server.server_name,
|
||||||
|
Server.api_url,
|
||||||
|
Server.panel_type,
|
||||||
|
Server.enabled,
|
||||||
|
Server.max_keys,
|
||||||
|
).where(Server.cluster_name == least_loaded_cluster)
|
||||||
|
)
|
||||||
|
servers = result.mappings().all()
|
||||||
|
|
||||||
|
if not servers:
|
||||||
|
logger.error(f"❌ Нет серверов в кластере {least_loaded_cluster}")
|
||||||
|
text = "❌ Нет доступных серверов в выбранном кластере."
|
||||||
|
if safe_to_edit:
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=target_message, text=text, reply_markup=None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await bot.send_message(chat_id=tg_id, text=text)
|
||||||
|
return
|
||||||
|
|
||||||
|
available_servers = []
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(check_server_availability(server, session))
|
||||||
|
for server in servers
|
||||||
|
]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
for server, result in zip(servers, results, strict=False):
|
||||||
|
if result is True:
|
||||||
|
available_servers.append(server["server_name"])
|
||||||
|
|
||||||
|
if not available_servers:
|
||||||
|
logger.warning(
|
||||||
|
f"[Country Selection] Нет доступных серверов в кластере {least_loaded_cluster}"
|
||||||
|
)
|
||||||
|
text = "❌ Нет доступных серверов в выбранном кластере."
|
||||||
|
if safe_to_edit:
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=target_message, text=text, reply_markup=None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await bot.send_message(chat_id=tg_id, text=text)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Country Selection] Доступные сервера в кластере {least_loaded_cluster}: {available_servers}"
|
||||||
|
)
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
ts = int(expiry_time.timestamp())
|
||||||
|
for server_name in available_servers:
|
||||||
|
if old_key_name:
|
||||||
|
callback_data = f"select_country|{server_name}|{ts}|{old_key_name}"
|
||||||
|
else:
|
||||||
|
callback_data = f"select_country|{server_name}|{ts}"
|
||||||
|
builder.row(InlineKeyboardButton(text=server_name, callback_data=callback_data))
|
||||||
|
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
if safe_to_edit:
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=target_message,
|
||||||
|
text=SELECT_COUNTRY_MSG,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=tg_id,
|
||||||
|
text=SELECT_COUNTRY_MSG,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("change_location|"))
|
||||||
|
async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||||
|
try:
|
||||||
|
data = callback_query.data.split("|")
|
||||||
|
if len(data) < 2:
|
||||||
|
await callback_query.answer("❌ Некорректные данные", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
old_key_name = data[1]
|
||||||
|
record = await get_key_details(session, old_key_name)
|
||||||
|
if not record:
|
||||||
|
await callback_query.answer("❌ Ключ не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
expiry_timestamp = record["expiry_time"]
|
||||||
|
ts = int(expiry_timestamp / 1000)
|
||||||
|
|
||||||
|
current_server = record["server_id"]
|
||||||
|
|
||||||
|
cluster_info = await check_server_name_by_cluster(session, current_server)
|
||||||
|
if not cluster_info:
|
||||||
|
await callback_query.answer(
|
||||||
|
"❌ Кластер для текущего сервера не найден", show_alert=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cluster_name = cluster_info["cluster_name"]
|
||||||
|
|
||||||
|
servers = (
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
Server.server_name,
|
||||||
|
Server.api_url,
|
||||||
|
Server.panel_type,
|
||||||
|
Server.enabled,
|
||||||
|
Server.max_keys,
|
||||||
|
)
|
||||||
|
.where(Server.cluster_name == cluster_name)
|
||||||
|
.where(Server.server_name != current_server)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not servers:
|
||||||
|
await callback_query.answer(
|
||||||
|
"❌ Доступных серверов в кластере не найдено", show_alert=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
available_servers = []
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
for server in servers:
|
||||||
|
server_info = {
|
||||||
|
"server_name": server["server_name"],
|
||||||
|
"api_url": server["api_url"],
|
||||||
|
"panel_type": server["panel_type"],
|
||||||
|
"enabled": server.get("enabled", True),
|
||||||
|
"max_keys": server.get("max_keys"),
|
||||||
|
}
|
||||||
|
task = asyncio.create_task(check_server_availability(server_info, session))
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
for server, result in zip(servers, results, strict=False):
|
||||||
|
if result is True:
|
||||||
|
available_servers.append(server["server_name"])
|
||||||
|
|
||||||
|
if not available_servers:
|
||||||
|
await callback_query.answer(
|
||||||
|
"❌ Нет доступных серверов для смены локации", show_alert=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Доступные страны для смены локации: {available_servers}")
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for country in available_servers:
|
||||||
|
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
||||||
|
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}")
|
||||||
|
)
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path=None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}"
|
||||||
|
)
|
||||||
|
await callback_query.answer(
|
||||||
|
"❌ Ошибка смены локации. Попробуйте снова.", show_alert=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("select_country|"))
|
||||||
|
async def handle_country_selection(
|
||||||
|
callback_query: CallbackQuery, session: Any, state: FSMContext
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Обрабатывает выбор страны.
|
||||||
|
Формат callback data:
|
||||||
|
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
|
||||||
|
Если передан old_key_name – значит, происходит смена локации.
|
||||||
|
"""
|
||||||
|
data = callback_query.data.split("|")
|
||||||
|
if len(data) < 3:
|
||||||
|
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_country = data[1]
|
||||||
|
try:
|
||||||
|
ts = int(data[2])
|
||||||
|
except ValueError:
|
||||||
|
await callback_query.message.answer(
|
||||||
|
"❌ Некорректное время истечения. Попробуйте снова."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
|
||||||
|
|
||||||
|
old_key_name = data[3] if len(data) > 3 else None
|
||||||
|
|
||||||
|
tg_id = callback_query.from_user.id
|
||||||
|
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
|
||||||
|
logger.info(f"Получено время истечения (timestamp): {ts}")
|
||||||
|
|
||||||
|
await finalize_key_creation(
|
||||||
|
tg_id,
|
||||||
|
expiry_time,
|
||||||
|
selected_country,
|
||||||
|
state,
|
||||||
|
session,
|
||||||
|
callback_query,
|
||||||
|
old_key_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def finalize_key_creation(
|
||||||
|
tg_id: int,
|
||||||
|
expiry_time: datetime,
|
||||||
|
selected_country: str,
|
||||||
|
state: FSMContext | None,
|
||||||
|
session: AsyncSession,
|
||||||
|
callback_query: CallbackQuery,
|
||||||
|
old_key_name: str = None,
|
||||||
|
tariff_id: int | None = None,
|
||||||
|
):
|
||||||
|
from_user = callback_query.from_user
|
||||||
|
|
||||||
|
if not await check_user_exists(session, tg_id):
|
||||||
|
await add_user(
|
||||||
|
session=session,
|
||||||
|
tg_id=from_user.id,
|
||||||
|
username=from_user.username,
|
||||||
|
first_name=from_user.first_name,
|
||||||
|
last_name=from_user.last_name,
|
||||||
|
language_code=from_user.language_code,
|
||||||
|
is_bot=from_user.is_bot,
|
||||||
|
)
|
||||||
|
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
|
||||||
|
|
||||||
|
expiry_time = expiry_time.astimezone(moscow_tz)
|
||||||
|
|
||||||
|
if old_key_name:
|
||||||
|
old_key_details = await get_key_details(session, old_key_name)
|
||||||
|
if not old_key_details:
|
||||||
|
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
|
||||||
|
return
|
||||||
|
|
||||||
|
key_name = old_key_name
|
||||||
|
client_id = old_key_details["client_id"]
|
||||||
|
email = old_key_details["email"]
|
||||||
|
expiry_timestamp = old_key_details["expiry_time"]
|
||||||
|
tariff_id = old_key_details.get("tariff_id") or tariff_id
|
||||||
|
else:
|
||||||
|
while True:
|
||||||
|
key_name = generate_random_email()
|
||||||
|
existing_key = await get_key_details(session, key_name)
|
||||||
|
if not existing_key:
|
||||||
|
break
|
||||||
|
client_id = str(uuid.uuid4())
|
||||||
|
email = key_name.lower()
|
||||||
|
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||||
|
|
||||||
|
traffic_limit_bytes = None
|
||||||
|
device_limit = 0
|
||||||
|
data = await state.get_data() if state else {}
|
||||||
|
is_trial = data.get("is_trial", False)
|
||||||
|
|
||||||
|
if is_trial:
|
||||||
|
traffic_limit_bytes = int(TRIAL_CONFIG.get("traffic_limit_gb", 100)) * 1024**3
|
||||||
|
device_limit = TRIAL_CONFIG.get("hwid_limit", 1)
|
||||||
|
elif data.get("tariff_id") or tariff_id:
|
||||||
|
tariff_id = data.get("tariff_id") or tariff_id
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff = result.scalar_one_or_none()
|
||||||
|
if tariff:
|
||||||
|
if tariff.traffic_limit is not None:
|
||||||
|
traffic_limit_bytes = int(tariff.traffic_limit) * 1024**3
|
||||||
|
if tariff.device_limit is not None:
|
||||||
|
device_limit = int(tariff.device_limit)
|
||||||
|
|
||||||
|
public_link = None
|
||||||
|
remnawave_link = None
|
||||||
|
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Server).where(Server.server_name == selected_country)
|
||||||
|
)
|
||||||
|
server_info = result.scalar_one_or_none()
|
||||||
|
if not server_info:
|
||||||
|
raise ValueError(f"Сервер {selected_country} не найден")
|
||||||
|
|
||||||
|
panel_type = server_info.panel_type.lower()
|
||||||
|
cluster_info = await check_server_name_by_cluster(session, server_info.server_name)
|
||||||
|
if not cluster_info:
|
||||||
|
raise ValueError(f"Кластер для сервера {server_info.server_name} не найден")
|
||||||
|
|
||||||
|
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
|
||||||
|
|
||||||
|
if old_key_name:
|
||||||
|
old_server_id = old_key_details["server_id"]
|
||||||
|
if old_server_id:
|
||||||
|
result = await session.execute(select(Server).where(Server.server_name == old_server_id))
|
||||||
|
old_server_info = result.scalar_one_or_none()
|
||||||
|
if old_server_info:
|
||||||
|
try:
|
||||||
|
if old_server_info.panel_type.lower() == "3x-ui":
|
||||||
|
xui = await get_xui_instance(old_server_info.api_url)
|
||||||
|
await delete_client(xui, old_server_info.inbound_id, email, client_id)
|
||||||
|
await session.execute(
|
||||||
|
update(Key)
|
||||||
|
.where(Key.tg_id == tg_id, Key.email == email)
|
||||||
|
.values(key=None)
|
||||||
|
)
|
||||||
|
elif old_server_info.panel_type.lower() == "remnawave":
|
||||||
|
remna = RemnawaveAPI(old_server_info.api_url)
|
||||||
|
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||||
|
await remna.delete_user(client_id)
|
||||||
|
await session.execute(
|
||||||
|
update(Key)
|
||||||
|
.where(Key.tg_id == tg_id, Key.email == email)
|
||||||
|
.values(remnawave_link=None)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Delete] Ошибка при удалении клиента: {e}")
|
||||||
|
|
||||||
|
if panel_type == "remnawave" or is_full_remnawave:
|
||||||
|
remna = RemnawaveAPI(server_info.api_url)
|
||||||
|
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||||
|
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({server_info.server_name})")
|
||||||
|
|
||||||
|
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||||
|
user_data = {
|
||||||
|
"username": email,
|
||||||
|
"trafficLimitStrategy": "NO_RESET",
|
||||||
|
"expireAt": expire_at,
|
||||||
|
"telegramId": tg_id,
|
||||||
|
"activeUserInbounds": [server_info.inbound_id],
|
||||||
|
}
|
||||||
|
if traffic_limit_bytes:
|
||||||
|
user_data["trafficLimitBytes"] = traffic_limit_bytes
|
||||||
|
if device_limit:
|
||||||
|
user_data["hwidDeviceLimit"] = device_limit
|
||||||
|
|
||||||
|
result = await remna.create_user(user_data)
|
||||||
|
if not result:
|
||||||
|
raise ValueError("❌ Ошибка при создании пользователя в Remnawave")
|
||||||
|
|
||||||
|
client_id = result.get("uuid")
|
||||||
|
remnawave_link = result.get("subscriptionUrl")
|
||||||
|
|
||||||
|
if old_key_name:
|
||||||
|
await session.execute(
|
||||||
|
update(Key).where(Key.tg_id == tg_id, Key.email == email).values(client_id=client_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if panel_type == "3x-ui":
|
||||||
|
semaphore = asyncio.Semaphore(2)
|
||||||
|
await create_client_on_server(
|
||||||
|
server_info={
|
||||||
|
"api_url": server_info.api_url,
|
||||||
|
"inbound_id": server_info.inbound_id,
|
||||||
|
"server_name": server_info.server_name,
|
||||||
|
"panel_type": server_info.panel_type,
|
||||||
|
},
|
||||||
|
tg_id=tg_id,
|
||||||
|
client_id=client_id,
|
||||||
|
email=email,
|
||||||
|
expiry_timestamp=expiry_timestamp,
|
||||||
|
semaphore=semaphore,
|
||||||
|
session=session,
|
||||||
|
plan=tariff_id,
|
||||||
|
is_trial=is_trial,
|
||||||
|
)
|
||||||
|
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||||
|
|
||||||
|
logger.info(f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}")
|
||||||
|
|
||||||
|
if old_key_name:
|
||||||
|
update_data = {"server_id": selected_country}
|
||||||
|
if panel_type == "3x-ui":
|
||||||
|
update_data["key"] = public_link
|
||||||
|
elif panel_type == "remnawave":
|
||||||
|
update_data["remnawave_link"] = remnawave_link
|
||||||
|
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.email == email).values(**update_data))
|
||||||
|
else:
|
||||||
|
new_key = Key(
|
||||||
|
tg_id=tg_id,
|
||||||
|
client_id=client_id,
|
||||||
|
email=email,
|
||||||
|
created_at=created_at,
|
||||||
|
expiry_time=expiry_timestamp,
|
||||||
|
key=public_link,
|
||||||
|
remnawave_link=remnawave_link,
|
||||||
|
server_id=selected_country,
|
||||||
|
tariff_id=tariff_id,
|
||||||
|
)
|
||||||
|
session.add(new_key)
|
||||||
|
|
||||||
|
if is_trial:
|
||||||
|
trial_status = await get_trial(session, tg_id)
|
||||||
|
if trial_status in [0, -1]:
|
||||||
|
await update_trial(session, tg_id, 1)
|
||||||
|
|
||||||
|
if tariff_id:
|
||||||
|
result = await session.execute(select(Tariff.price_rub).where(Tariff.id == tariff_id))
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
if row:
|
||||||
|
await update_balance(session, tg_id, -row)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||||
|
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Попробуйте снова.")
|
||||||
|
return
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
|
||||||
|
if (panel_type == "remnawave" or is_full_remnawave) and (public_link or remnawave_link):
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=CONNECT_DEVICE,
|
||||||
|
web_app=WebAppInfo(url=public_link or remnawave_link),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")
|
||||||
|
)
|
||||||
|
elif CONNECT_PHONE_BUTTON:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}")
|
||||||
|
)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||||
|
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
|
||||||
|
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
link_to_show = public_link or remnawave_link or "Ссылка не найдена"
|
||||||
|
|
||||||
|
tariff_info = None
|
||||||
|
if tariff_id:
|
||||||
|
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||||
|
tariff_info = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if is_trial:
|
||||||
|
trial_days = TRIAL_CONFIG.get("duration_days", 1)
|
||||||
|
if trial_days >= 30:
|
||||||
|
months = trial_days // 30
|
||||||
|
tariff_duration = format_months(months)
|
||||||
|
else:
|
||||||
|
tariff_duration = format_days(trial_days)
|
||||||
|
key_message_text = key_message_success(
|
||||||
|
link_to_show,
|
||||||
|
tariff_name=tariff_duration,
|
||||||
|
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
|
||||||
|
device_limit=TRIAL_CONFIG.get("hwid_limit", 1)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tariff_duration = ""
|
||||||
|
if tariff_info and tariff_info.get("duration_days", 0) > 0:
|
||||||
|
duration_days = tariff_info["duration_days"]
|
||||||
|
if duration_days >= 30:
|
||||||
|
months = duration_days // 30
|
||||||
|
tariff_duration = format_months(months)
|
||||||
|
else:
|
||||||
|
tariff_duration = format_days(duration_days)
|
||||||
|
|
||||||
|
key_message_text = key_message_success(
|
||||||
|
link_to_show,
|
||||||
|
tariff_name=tariff_duration,
|
||||||
|
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
|
||||||
|
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=key_message_text,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
media_path="img/pic.jpg",
|
||||||
|
)
|
||||||
|
|
||||||
|
if state:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_server_availability(server_info: dict, session: AsyncSession) -> bool:
|
||||||
|
server_name = server_info.get("server_name", "unknown")
|
||||||
|
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||||
|
enabled = server_info.get("enabled", True)
|
||||||
|
max_keys = server_info.get("max_keys")
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
logger.info(f"[Ping] Сервер {server_name} выключен (enabled = FALSE).")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if max_keys is not None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Key)
|
||||||
|
.where(Key.server_id == server_name)
|
||||||
|
)
|
||||||
|
key_count = result.scalar()
|
||||||
|
|
||||||
|
if key_count >= max_keys:
|
||||||
|
logger.info(
|
||||||
|
f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if panel_type == "remnawave":
|
||||||
|
remna = RemnawaveAPI(server_info["api_url"])
|
||||||
|
await asyncio.wait_for(
|
||||||
|
remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0
|
||||||
|
)
|
||||||
|
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
else:
|
||||||
|
xui = AsyncApi(
|
||||||
|
server_info["api_url"],
|
||||||
|
username=ADMIN_USERNAME,
|
||||||
|
password=ADMIN_PASSWORD,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(xui.login(), timeout=5.0)
|
||||||
|
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Ping] Ошибка при проверке сервера {server_name}: {e}")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from math import ceil
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
NOTIFY_EXTRA_DAYS,
|
||||||
|
TRIAL_CONFIG,
|
||||||
|
TRIAL_TIME_DISABLE,
|
||||||
|
USE_COUNTRY_SELECTION,
|
||||||
|
USE_NEW_PAYMENT_FLOW,
|
||||||
|
)
|
||||||
|
from database import (
|
||||||
|
add_user,
|
||||||
|
check_user_exists,
|
||||||
|
create_temporary_data,
|
||||||
|
get_balance,
|
||||||
|
get_tariff_by_id,
|
||||||
|
get_tariffs_for_cluster,
|
||||||
|
get_trial,
|
||||||
|
)
|
||||||
|
from handlers.buttons import MAIN_MENU, PAYMENT
|
||||||
|
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||||
|
from handlers.payments.stars_pay import process_custom_amount_input_stars
|
||||||
|
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||||
|
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
|
||||||
|
from handlers.texts import (
|
||||||
|
CREATING_CONNECTION_MSG,
|
||||||
|
INSUFFICIENT_FUNDS_MSG,
|
||||||
|
SELECT_TARIFF_PLAN_MSG,
|
||||||
|
)
|
||||||
|
from handlers.utils import edit_or_send_message, get_least_loaded_cluster
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from .key_cluster_mode import key_cluster_mode
|
||||||
|
from .key_country_mode import key_country_mode
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
|
||||||
|
class Form(FSMContext):
|
||||||
|
waiting_for_server_selection = "waiting_for_server_selection"
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "create_key")
|
||||||
|
async def confirm_create_new_key(
|
||||||
|
callback_query: CallbackQuery, state: FSMContext, session: Any
|
||||||
|
):
|
||||||
|
tg_id = callback_query.message.chat.id
|
||||||
|
await handle_key_creation(tg_id, state, session, callback_query)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_key_creation(
|
||||||
|
tg_id: int,
|
||||||
|
state: FSMContext,
|
||||||
|
session: Any,
|
||||||
|
message_or_query: Message | CallbackQuery,
|
||||||
|
):
|
||||||
|
current_time = datetime.now(moscow_tz)
|
||||||
|
|
||||||
|
if not TRIAL_TIME_DISABLE:
|
||||||
|
trial_status = await get_trial(session, tg_id)
|
||||||
|
if trial_status in [0, -1]:
|
||||||
|
base_days = TRIAL_CONFIG["duration_days"]
|
||||||
|
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
||||||
|
total_days = base_days + extra_days
|
||||||
|
expiry_time = current_time + timedelta(days=total_days)
|
||||||
|
|
||||||
|
logger.info(f"[Trial] Доступен {total_days}-дневный триал для пользователя {tg_id}")
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=(
|
||||||
|
message_or_query.message
|
||||||
|
if isinstance(message_or_query, CallbackQuery)
|
||||||
|
else message_or_query
|
||||||
|
),
|
||||||
|
text=CREATING_CONNECTION_MSG,
|
||||||
|
reply_markup=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.update_data(is_trial=True)
|
||||||
|
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||||
|
return
|
||||||
|
|
||||||
|
cluster_name = await get_least_loaded_cluster(session)
|
||||||
|
tariffs = await get_tariffs_for_cluster(session, cluster_name)
|
||||||
|
|
||||||
|
if not tariffs:
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=(
|
||||||
|
message_or_query.message
|
||||||
|
if isinstance(message_or_query, CallbackQuery)
|
||||||
|
else message_or_query
|
||||||
|
),
|
||||||
|
text="❌ Нет доступных тарифов для выбора.",
|
||||||
|
reply_markup=None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for t in tariffs:
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{t['name']} — {t['price_rub']}₽",
|
||||||
|
callback_data=f"select_tariff_plan|{t['id']}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
|
||||||
|
target_message = (
|
||||||
|
message_or_query.message
|
||||||
|
if isinstance(message_or_query, CallbackQuery)
|
||||||
|
else message_or_query
|
||||||
|
)
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=target_message,
|
||||||
|
text=SELECT_TARIFF_PLAN_MSG,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.update_data(tg_id=tg_id)
|
||||||
|
await state.set_state(Form.waiting_for_server_selection)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("select_tariff_plan|"))
|
||||||
|
async def select_tariff_plan(
|
||||||
|
callback_query: CallbackQuery, session: Any, state: FSMContext
|
||||||
|
):
|
||||||
|
tg_id = callback_query.from_user.id
|
||||||
|
tariff_id = int(callback_query.data.split("|")[1])
|
||||||
|
|
||||||
|
tariff = await get_tariff_by_id(session, tariff_id)
|
||||||
|
if not tariff:
|
||||||
|
await callback_query.message.edit_text("❌ Указанный тариф не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
duration_days = tariff["duration_days"]
|
||||||
|
price_rub = tariff["price_rub"]
|
||||||
|
|
||||||
|
balance = await get_balance(session, tg_id)
|
||||||
|
price_rub = tariff["price_rub"]
|
||||||
|
|
||||||
|
if balance < price_rub:
|
||||||
|
required_amount = ceil(price_rub - balance)
|
||||||
|
await create_temporary_data(
|
||||||
|
session,
|
||||||
|
tg_id,
|
||||||
|
"waiting_for_payment",
|
||||||
|
{
|
||||||
|
"tariff_id": tariff_id,
|
||||||
|
"duration_days": duration_days,
|
||||||
|
"required_amount": required_amount,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||||
|
await process_custom_amount_input(callback_query, session)
|
||||||
|
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||||
|
await handle_custom_amount_input(callback_query, session)
|
||||||
|
elif USE_NEW_PAYMENT_FLOW == "STARS":
|
||||||
|
await process_custom_amount_input_stars(callback_query, session)
|
||||||
|
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
|
||||||
|
await process_custom_amount_input_yoomoney(callback_query, session)
|
||||||
|
else:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||||
|
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key")
|
||||||
|
)
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=CREATING_CONNECTION_MSG,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
|
||||||
|
await state.update_data(tariff_id=tariff_id)
|
||||||
|
await create_key(tg_id, expiry_time, state, session, callback_query, plan=tariff_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_key(
|
||||||
|
tg_id: int,
|
||||||
|
expiry_time,
|
||||||
|
state,
|
||||||
|
session,
|
||||||
|
message_or_query=None,
|
||||||
|
old_key_name: str = None,
|
||||||
|
plan: int = None,
|
||||||
|
):
|
||||||
|
if not await check_user_exists(session, tg_id):
|
||||||
|
from_user = (
|
||||||
|
message_or_query.from_user
|
||||||
|
if isinstance(message_or_query, CallbackQuery | Message)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if from_user:
|
||||||
|
await add_user(
|
||||||
|
tg_id=from_user.id,
|
||||||
|
username=from_user.username,
|
||||||
|
first_name=from_user.first_name,
|
||||||
|
last_name=from_user.last_name,
|
||||||
|
language_code=from_user.language_code,
|
||||||
|
is_bot=from_user.is_bot,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
|
||||||
|
|
||||||
|
if USE_COUNTRY_SELECTION:
|
||||||
|
await key_country_mode(
|
||||||
|
tg_id=tg_id,
|
||||||
|
expiry_time=expiry_time,
|
||||||
|
state=state,
|
||||||
|
session=session,
|
||||||
|
message_or_query=message_or_query,
|
||||||
|
old_key_name=old_key_name,
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await key_cluster_mode(
|
||||||
|
tg_id=tg_id,
|
||||||
|
expiry_time=expiry_time,
|
||||||
|
state=state,
|
||||||
|
session=session,
|
||||||
|
message_or_query=message_or_query,
|
||||||
|
plan=plan,
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import DISCOUNT_ACTIVE_HOURS
|
||||||
|
from database import get_tariffs
|
||||||
|
from database.models import Notification
|
||||||
|
from handlers.notifications.notify_kb import build_tariffs_keyboard
|
||||||
|
from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
from .key_create import select_tariff_plan
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "hot_lead_discount")
|
||||||
|
async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
tg_id = callback.from_user.id
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Notification.last_notification_time).where(
|
||||||
|
Notification.tg_id == tg_id,
|
||||||
|
Notification.notification_type == "hot_lead_step_2",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
last_time = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not last_time:
|
||||||
|
await callback.message.edit_text("❌ Скидка недоступна.")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if now - last_time > timedelta(hours=DISCOUNT_ACTIVE_HOURS):
|
||||||
|
await callback.message.edit_text("⏳ Срок действия скидки истёк.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tariffs = await get_tariffs(session=session, group_code="discounts")
|
||||||
|
if not tariffs:
|
||||||
|
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
DISCOUNT_TARIFF,
|
||||||
|
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("discount_tariff|"))
|
||||||
|
async def handle_discount_tariff_selection(callback: CallbackQuery, session, state):
|
||||||
|
try:
|
||||||
|
tariff_id = int(callback.data.split("|")[1])
|
||||||
|
fake_callback = CallbackQuery.model_construct(
|
||||||
|
id=callback.id,
|
||||||
|
from_user=callback.from_user,
|
||||||
|
chat_instance=callback.chat_instance,
|
||||||
|
message=callback.message,
|
||||||
|
data=f"select_tariff_plan|{tariff_id}",
|
||||||
|
)
|
||||||
|
await select_tariff_plan(fake_callback, session=session, state=state)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при выборе скидочного тарифа: {e}")
|
||||||
|
await callback.message.answer("❌ Произошла ошибка при выборе тарифа.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "hot_lead_final_discount")
|
||||||
|
async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
tg_id = callback.from_user.id
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Notification.last_notification_time).where(
|
||||||
|
Notification.tg_id == tg_id,
|
||||||
|
Notification.notification_type == "hot_lead_step_3",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
last_time = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not last_time:
|
||||||
|
await callback.message.edit_text("❌ Скидка недоступна.")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if now - last_time > timedelta(hours=DISCOUNT_ACTIVE_HOURS):
|
||||||
|
await callback.message.edit_text("⏳ Срок действия финальной скидки истёк.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tariffs = await get_tariffs(session, group_code="discounts_max")
|
||||||
|
if not tariffs:
|
||||||
|
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
DISCOUNT_TARIFF_MAX,
|
||||||
|
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from sqlalchemy import or_, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from math import ceil
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from bot import bot
|
||||||
|
from config import USE_NEW_PAYMENT_FLOW
|
||||||
|
from database import (
|
||||||
|
create_temporary_data,
|
||||||
|
get_balance,
|
||||||
|
get_key_by_server,
|
||||||
|
get_key_details,
|
||||||
|
get_tariff_by_id,
|
||||||
|
get_tariffs,
|
||||||
|
update_balance,
|
||||||
|
update_key_expiry,
|
||||||
|
check_tariff_exists,
|
||||||
|
)
|
||||||
|
from database.models import Server, Key
|
||||||
|
from handlers.buttons import BACK, MAIN_MENU, PAYMENT
|
||||||
|
from handlers.keys.key_utils import renew_key_in_cluster
|
||||||
|
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||||
|
from handlers.payments.stars_pay import process_custom_amount_input_stars
|
||||||
|
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||||
|
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
|
||||||
|
from handlers.texts import (
|
||||||
|
INSUFFICIENT_FUNDS_RENEWAL_MSG,
|
||||||
|
KEY_NOT_FOUND_MSG,
|
||||||
|
PLAN_SELECTION_MSG,
|
||||||
|
get_renewal_message,
|
||||||
|
)
|
||||||
|
from handlers.buttons import MY_SUB
|
||||||
|
from handlers.utils import edit_or_send_message, format_days, format_months, get_russian_month
|
||||||
|
from logger import logger
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("renew_key|"))
|
||||||
|
async def process_callback_renew_key(
|
||||||
|
callback_query: CallbackQuery, session: AsyncSession
|
||||||
|
):
|
||||||
|
tg_id = callback_query.message.chat.id
|
||||||
|
key_name = callback_query.data.split("|")[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = await get_key_details(session, key_name)
|
||||||
|
if not record:
|
||||||
|
await callback_query.message.answer("<b>Ключ не найден.</b>")
|
||||||
|
return
|
||||||
|
|
||||||
|
client_id = record["client_id"]
|
||||||
|
expiry_time = record["expiry_time"]
|
||||||
|
server_id = record["server_id"]
|
||||||
|
tariff_id = record.get("tariff_id")
|
||||||
|
|
||||||
|
logger.info(f"[RENEW] Получение тарифов для server_id={server_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
server_id_int = int(server_id)
|
||||||
|
filter_condition = or_(
|
||||||
|
Server.id == server_id_int,
|
||||||
|
Server.server_name == server_id,
|
||||||
|
Server.cluster_name == server_id,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
filter_condition = or_(
|
||||||
|
Server.server_name == server_id,
|
||||||
|
Server.cluster_name == server_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = await session.execute(
|
||||||
|
select(Server.tariff_group).where(filter_condition).limit(1)
|
||||||
|
)
|
||||||
|
row = row.first()
|
||||||
|
if not row or not row[0]:
|
||||||
|
logger.warning(f"[RENEW] Тарифная группа не найдена для server_id={server_id}")
|
||||||
|
await callback_query.message.answer("❌ Не удалось определить тарифную группу.")
|
||||||
|
return
|
||||||
|
|
||||||
|
cluster_group = row[0]
|
||||||
|
selected_tariffs = []
|
||||||
|
target_group = cluster_group
|
||||||
|
|
||||||
|
if tariff_id:
|
||||||
|
if await check_tariff_exists(session, tariff_id):
|
||||||
|
current_tariff = await get_tariff_by_id(session, tariff_id)
|
||||||
|
|
||||||
|
if current_tariff["group_code"] not in ["discounts", "discounts_max", "gifts"]:
|
||||||
|
target_group = current_tariff["group_code"]
|
||||||
|
|
||||||
|
tariffs = await get_tariffs(session, group_code=target_group)
|
||||||
|
|
||||||
|
if not tariffs:
|
||||||
|
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_tariffs = [t for t in tariffs if t["is_active"]]
|
||||||
|
|
||||||
|
if not selected_tariffs:
|
||||||
|
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
|
||||||
|
return
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
for t in selected_tariffs:
|
||||||
|
button_text = f"{t['name']} — {t['price_rub']}₽"
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=button_text,
|
||||||
|
callback_data=f"renew_plan|{t['id']}|{client_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}")
|
||||||
|
)
|
||||||
|
|
||||||
|
balance = await get_balance(session, tg_id)
|
||||||
|
response_message = PLAN_SELECTION_MSG.format(
|
||||||
|
balance=balance,
|
||||||
|
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=response_message,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {e}"
|
||||||
|
)
|
||||||
|
await callback_query.message.answer(
|
||||||
|
"❌ Произошла ошибка при обработке. Попробуйте позже."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||||
|
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
|
||||||
|
tg_id = callback_query.from_user.id
|
||||||
|
tariff_id, client_id = callback_query.data.split("|")[1:]
|
||||||
|
tariff_id = int(tariff_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tariff = await get_tariff_by_id(session, tariff_id)
|
||||||
|
if not tariff or not tariff["is_active"]:
|
||||||
|
await callback_query.message.answer("❌ Тариф не найден или отключён.")
|
||||||
|
return
|
||||||
|
|
||||||
|
duration_days = tariff["duration_days"]
|
||||||
|
cost = tariff["price_rub"]
|
||||||
|
total_gb = tariff["traffic_limit"] or 0
|
||||||
|
|
||||||
|
record = await get_key_by_server(session, tg_id, client_id)
|
||||||
|
if not record:
|
||||||
|
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
|
||||||
|
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
email = record["email"]
|
||||||
|
expiry_time = record["expiry_time"]
|
||||||
|
current_time = datetime.utcnow().timestamp() * 1000
|
||||||
|
|
||||||
|
if expiry_time <= current_time:
|
||||||
|
new_expiry_time = int(
|
||||||
|
current_time + timedelta(days=duration_days).total_seconds() * 1000
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_expiry_time = int(
|
||||||
|
expiry_time + timedelta(days=duration_days).total_seconds() * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
balance = round(await get_balance(session, tg_id), 2)
|
||||||
|
cost = round(cost, 2)
|
||||||
|
if balance < cost:
|
||||||
|
required_amount = ceil(cost - balance)
|
||||||
|
logger.info(f"[RENEW] Недостаточно средств: {required_amount}₽")
|
||||||
|
|
||||||
|
await create_temporary_data(
|
||||||
|
session,
|
||||||
|
tg_id,
|
||||||
|
"waiting_for_renewal_payment",
|
||||||
|
{
|
||||||
|
"tariff_id": tariff_id,
|
||||||
|
"client_id": client_id,
|
||||||
|
"cost": cost,
|
||||||
|
"required_amount": required_amount,
|
||||||
|
"new_expiry_time": new_expiry_time,
|
||||||
|
"total_gb": total_gb,
|
||||||
|
"email": email,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||||
|
await process_custom_amount_input(callback_query, session)
|
||||||
|
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||||
|
await handle_custom_amount_input(callback_query, session)
|
||||||
|
elif USE_NEW_PAYMENT_FLOW == "STARS":
|
||||||
|
await process_custom_amount_input_stars(callback_query, session)
|
||||||
|
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
|
||||||
|
await process_custom_amount_input_yoomoney(callback_query, session)
|
||||||
|
else:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
|
||||||
|
)
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(
|
||||||
|
required_amount=required_amount
|
||||||
|
),
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней"
|
||||||
|
)
|
||||||
|
await complete_key_renewal(
|
||||||
|
session,
|
||||||
|
tg_id,
|
||||||
|
client_id,
|
||||||
|
email,
|
||||||
|
new_expiry_time,
|
||||||
|
total_gb,
|
||||||
|
cost,
|
||||||
|
callback_query,
|
||||||
|
tariff_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_cluster_name(
|
||||||
|
session: AsyncSession, server_or_cluster: str
|
||||||
|
) -> str | None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Server).where(Server.cluster_name == server_or_cluster).limit(1)
|
||||||
|
)
|
||||||
|
server = result.scalars().first()
|
||||||
|
if server:
|
||||||
|
return server_or_cluster
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Server.cluster_name)
|
||||||
|
.where(Server.server_name == server_or_cluster)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
row = result.scalar()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def complete_key_renewal(
|
||||||
|
session: AsyncSession,
|
||||||
|
tg_id: int,
|
||||||
|
client_id: str,
|
||||||
|
email: str,
|
||||||
|
new_expiry_time: int,
|
||||||
|
total_gb: int,
|
||||||
|
cost: float,
|
||||||
|
callback_query: CallbackQuery | None,
|
||||||
|
tariff_id: int,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)"
|
||||||
|
)
|
||||||
|
|
||||||
|
tariff = await get_tariff_by_id(session, tariff_id)
|
||||||
|
if not tariff:
|
||||||
|
logger.error(f"[Error] Тариф с id={tariff_id} не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if tariff["duration_days"] % 30 == 0:
|
||||||
|
months_formatted = format_months(tariff["duration_days"] // 30)
|
||||||
|
else:
|
||||||
|
months_formatted = format_days(tariff["duration_days"])
|
||||||
|
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}"))
|
||||||
|
|
||||||
|
formatted_expiry_date = datetime.fromtimestamp(
|
||||||
|
new_expiry_time / 1000, tz=moscow_tz
|
||||||
|
).strftime("%d %B %Y, %H:%M")
|
||||||
|
|
||||||
|
formatted_expiry_date = formatted_expiry_date.replace(
|
||||||
|
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
|
||||||
|
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz))
|
||||||
|
)
|
||||||
|
|
||||||
|
response_message = get_renewal_message(
|
||||||
|
tariff_name=months_formatted,
|
||||||
|
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0,
|
||||||
|
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
|
||||||
|
expiry_date=formatted_expiry_date
|
||||||
|
)
|
||||||
|
|
||||||
|
if callback_query:
|
||||||
|
try:
|
||||||
|
await edit_or_send_message(
|
||||||
|
target_message=callback_query.message,
|
||||||
|
text=response_message,
|
||||||
|
reply_markup=builder.as_markup(),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
|
||||||
|
await callback_query.message.answer(
|
||||||
|
response_message, reply_markup=builder.as_markup()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await bot.send_message(
|
||||||
|
tg_id, response_message, reply_markup=builder.as_markup()
|
||||||
|
)
|
||||||
|
|
||||||
|
key_info = await get_key_details(session, email)
|
||||||
|
if not key_info:
|
||||||
|
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
|
||||||
|
return
|
||||||
|
|
||||||
|
server_or_cluster = key_info["server_id"]
|
||||||
|
cluster_id = await resolve_cluster_name(session, server_or_cluster)
|
||||||
|
|
||||||
|
if not cluster_id:
|
||||||
|
logger.error(f"[Error] Кластер для {server_or_cluster} не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await renew_key_in_cluster(
|
||||||
|
cluster_id,
|
||||||
|
email,
|
||||||
|
client_id,
|
||||||
|
new_expiry_time,
|
||||||
|
total_gb,
|
||||||
|
session,
|
||||||
|
hwid_device_limit=tariff["device_limit"],
|
||||||
|
)
|
||||||
|
|
||||||
|
await update_key_expiry(session, client_id, new_expiry_time)
|
||||||
|
await session.execute(
|
||||||
|
update(Key)
|
||||||
|
.where(Key.client_id == client_id)
|
||||||
|
.values(tariff_id=tariff_id)
|
||||||
|
)
|
||||||
|
await update_balance(session, tg_id, -cost)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Error] Ошибка в complete_key_renewal: {e}")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user