Compare commits
1464 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c7da4b9fd | |||
| ea6538a487 | |||
| f7d792963c | |||
| f6eeeb8ea0 | |||
| 738a72b628 | |||
| 784e38c345 | |||
| da44b4e041 | |||
| 12dd8f75fb | |||
| 057a71cfce | |||
| 3c25fb3015 | |||
| a573fdc4d4 | |||
| a6c34beff1 | |||
| a256a2f338 | |||
| e6313bf5a3 | |||
| 8fdd2dd3c4 | |||
| 7090daefc5 | |||
| d1e739c029 | |||
| 92c2a1f2c7 | |||
| 0ca56d0967 | |||
| 99bd6ce42f | |||
| d012d7a9c4 | |||
| 82fc4a9b33 | |||
| 5a769f8972 | |||
| a1e7db2ac5 | |||
| a1347dff04 | |||
| 5a4df05b7b | |||
| eca7009a55 | |||
| bdab2360c3 | |||
| 8d80360615 | |||
| 50c73821a6 | |||
| f1a5465e36 | |||
| 7938a0dc4f | |||
| 24c7adc73c | |||
| 42f1e81436 | |||
| 66900c284c | |||
| 4aa764a126 | |||
| 2e866103df | |||
| 3d7c76b113 | |||
| 15e9982530 | |||
| 199ea9f153 | |||
| 32a2ea8d13 | |||
| 5d73fd4960 | |||
| 8f3882819a | |||
| ebf1b22094 | |||
| b09bde32e4 | |||
| 23e01da0fd | |||
| d127624121 | |||
| d073e1a3bd | |||
| 706d81f4bc | |||
| f43954cd77 | |||
| dbb2395639 | |||
| e4641a0e86 | |||
| 12a615b5a8 | |||
| b829d32236 | |||
| 52714536e3 | |||
| ebdd7e124c | |||
| be74a7cadc | |||
| 7a533dd0da | |||
| b5d13beb96 | |||
| e01e7b4d1e | |||
| d1837bd414 | |||
| 04365dd335 | |||
| 1b6b2b2956 | |||
| 353545ce8d | |||
| 20f83d1a82 | |||
| c424c41048 | |||
| 81751093d9 | |||
| b68b539c68 | |||
| 17b9e92809 | |||
| 4b8bea65be | |||
| c1e26ca1ce | |||
| 425ba29e12 | |||
| 7cc6ddb4ef | |||
| ea7310bcb8 | |||
| 284d79dd67 | |||
| 412a31b938 | |||
| 1a6450284d | |||
| 5e6333d08e | |||
| 3a23883e46 | |||
| b444367887 | |||
| 752292cb4a | |||
| b0482cca1a | |||
| 4e1d7e51a9 | |||
| e19e00aa04 | |||
| 45041982d5 | |||
| c4bafc6c1d | |||
| 5baf44640d | |||
| d6b920ff4d | |||
| 6d9e1aba4a | |||
| 840e5e4c53 | |||
| e16580857f | |||
| fc3871fd8e | |||
| b1f0462aab | |||
| 417089cef4 | |||
| c08072a87b | |||
| a10316d215 | |||
| 4d3d23ac58 | |||
| 9074876e3d | |||
| 1487fb2858 | |||
| 16d031d90e | |||
| 1afa57bff4 | |||
| 05c591336b | |||
| cd9b92d036 | |||
| 344a24a669 | |||
| a5883e9057 | |||
| e292accccb | |||
| ed5426dd71 | |||
| 9b5ab83b1f | |||
| 4795107178 | |||
| 379944200b | |||
| cb8453f415 | |||
| 69f86fe821 | |||
| 099be68d29 | |||
| 7ab58d4461 | |||
| 9bb95e8189 | |||
| a257e61349 | |||
| 44d6fe87dd | |||
| b01eabdcda | |||
| 2a3f346434 | |||
| 8597eed42d | |||
| 070773d28d | |||
| e2a6712382 | |||
| 4ab4eedd04 | |||
| e2e73e130e | |||
| fa1317a5fb | |||
| cb0a155597 | |||
| 00f24acc4e | |||
| f5b4ab65ea | |||
| b61c3daca8 | |||
| 83d93a833d | |||
| b999e4aced | |||
| 214c9f0c65 | |||
| 31545a4225 | |||
| 1441a16474 | |||
| cec91b711f | |||
| f87da6db7a | |||
| f02a6358e9 | |||
| 20c1bd6a75 | |||
| 254620774e | |||
| 3078089044 | |||
| 9c67c401b9 | |||
| af79f511e5 | |||
| b20ec194c5 | |||
| adfb556259 | |||
| e9ff8d851e | |||
| ac258cda37 | |||
| 5fc4ce8930 | |||
| 3aa7b1edc5 | |||
| 10eca6c6ac | |||
| b59b3f0e53 | |||
| 290ef28a7f | |||
| 66bb7c8d33 | |||
| b947a48ca2 | |||
| 8dde5acbbc | |||
| 760aab75cd | |||
| a6a77238f7 | |||
| f75b5e8312 | |||
| 39701b1f55 | |||
| 5f69a36077 | |||
| 2556d3842b | |||
| ffec36fa54 | |||
| da6815c5f1 | |||
| 76c0c468f5 | |||
| 357c8a01e7 | |||
| 595c312e2b | |||
| 28435d2943 | |||
| 171634522e | |||
| 424cb50942 | |||
| 15ce453523 | |||
| cfe8b2edcd | |||
| 3b64b28b86 | |||
| 918872e4b4 | |||
| f21b49d5fd | |||
| 2bf2d98c88 | |||
| 63cc967dfc | |||
| 9bea1d6a6c | |||
| 97234d8835 | |||
| c5e8917043 | |||
| b39ae1a1ee | |||
| d5190ea07e | |||
| 7f35ec0745 | |||
| 16b22101af | |||
| ccdb490b84 | |||
| c12e61fb41 | |||
| c877aef711 | |||
| a174b7bd81 | |||
| 43f4657089 | |||
| 50d2d5f716 | |||
| 34618a3de1 | |||
| 1b930460ca | |||
| fe439cd31d | |||
| 81733b041a | |||
| 7ac599aa18 | |||
| 279409600b | |||
| d701ce31c8 | |||
| 315459ed2e | |||
| 0dd3641151 | |||
| c610fb118e | |||
| 8f471a1e43 | |||
| c7b2f15131 | |||
| 12079cac34 | |||
| 96b283c6cb | |||
| aeee2ec352 | |||
| 19c0db3690 | |||
| ede74ef7af | |||
| 4607c81e7f | |||
| cc70ac8f14 | |||
| f7eb475292 | |||
| 38da3cca91 | |||
| bf1e454f0c | |||
| 36ea55a10d | |||
| 9ee2f0addf | |||
| 52aa6f81e2 | |||
| b1c53d5fad | |||
| 52d5f784a2 | |||
| cafe576478 | |||
| 9427cb20d9 | |||
| 68b9092749 | |||
| e3887fa66a | |||
| ffd08e726d | |||
| 03444b65ba | |||
| b817b5f087 | |||
| f93dc40f5f | |||
| 71c8014be1 | |||
| 33e44abef0 | |||
| e7f91a971b | |||
| 8ee133edc6 | |||
| 8954d3a756 | |||
| 0640c4f1fd | |||
| 2c9680cc8d | |||
| 9d3c0cb4b1 | |||
| 6bfa94d9bb | |||
| 58cda2e5ff | |||
| 2d4cdb0e35 | |||
| 1331632bdc | |||
| eadcacdedc | |||
| 855398d186 | |||
| 8bd09018ec | |||
| 36b074b209 | |||
| 318598026c | |||
| 755c85db2d | |||
| 9074eb277d | |||
| 55cbe579f7 | |||
| d9292840c5 | |||
| f817110476 | |||
| 4699c6a394 | |||
| 0abb83aae1 | |||
| d532f1211c | |||
| e4538ec0f2 | |||
| 157f7454bd | |||
| d9f6013aa1 | |||
| a3d8a6233a | |||
| 5ecb5b66f0 | |||
| c9797f9b26 | |||
| 19063b3592 | |||
| f0c1f62d62 | |||
| e7410ae776 | |||
| 606af6f52a | |||
| 3f22e20c24 | |||
| c2110e7304 | |||
| c07e55a86e | |||
| 48e15fbaad | |||
| e280a4dc12 | |||
| 493f01d6aa | |||
| a93855e1a6 | |||
| a9821aced7 | |||
| 98055bc1ab | |||
| 801dac4884 | |||
| a315222df7 | |||
| 34feb87b0a | |||
| ab04498488 | |||
| 6f8bc8ad24 | |||
| 1e6c901e43 | |||
| 2aade7acf9 | |||
| 9ade45347c | |||
| 1fb7f8d412 | |||
| aabfaf10ee | |||
| bbc907ef93 | |||
| 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 | |||
| 9fe28059c6 | |||
| 5742e507d0 | |||
| 5549d0a55c | |||
| dc76929283 | |||
| 5d5f2b0556 | |||
| 9d759b949d | |||
| 69b4da4d43 | |||
| f03f8a3711 | |||
| 594b97a752 | |||
| 04cbcbd817 | |||
| 7849d8c99f | |||
| d7ae68e17a | |||
| 7adb81ca28 | |||
| d207b64a97 | |||
| a104867a35 | |||
| 7dd00a2f24 | |||
| 0e4ca2ac47 | |||
| 19af987979 | |||
| edaeb3a212 | |||
| 0e43b36901 | |||
| cebcad4be1 | |||
| 34f0205f6d | |||
| fdf5b5e93d | |||
| 015fbf1d72 | |||
| b3fb4984b6 | |||
| 146b61a1a8 | |||
| 5dd4fda577 | |||
| f83278af90 | |||
| 3fa93a9b72 | |||
| be35180703 | |||
| 4e8f5e71a7 | |||
| b89db922f7 | |||
| 60c7fa14c8 | |||
| 9413065ea3 | |||
| 98fdf362c3 | |||
| 9c2b732d55 | |||
| b95013eb8f | |||
| b1e9b257c6 | |||
| 22a462d193 | |||
| 16a497a8df | |||
| 51b46bfe65 | |||
| 4316f44390 | |||
| 470f9a2f38 | |||
| fe6fef3174 | |||
| f8015ced70 | |||
| a6d2203e78 | |||
| 22b1342d43 | |||
| 0f321aab3a | |||
| c3e9cc2275 | |||
| cb9b6381ca | |||
| 7fa8d6f346 | |||
| 9398d1ec7e | |||
| b81d515551 | |||
| 2f523bf196 | |||
| f82e9af84e | |||
| 15181f0ef5 | |||
| 12295d276a | |||
| c98c6af14c | |||
| fc5606faa8 | |||
| 412893b689 | |||
| 4797759f9b | |||
| 9398b78238 | |||
| 86c86c9198 | |||
| 40365a5324 | |||
| 454ca55fd6 | |||
| 38aeaa9d17 | |||
| 1e259eb6e3 | |||
| b340d07f5c | |||
| 7c57cf8102 | |||
| ae819e33dc | |||
| c9f3aa4a20 |
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Issue
|
||||
about: Сообщить об ошибке, предложить улучшение или указать на уязвимость
|
||||
title: "[ISSUE] "
|
||||
labels: triage
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
### Тип
|
||||
- [ ] Баг
|
||||
- [ ] Фича
|
||||
- [ ] Безопасность
|
||||
|
||||
### Кратко
|
||||
Опишите суть задачи.
|
||||
|
||||
### Шаги воспроизведения (для багов/безопасности)
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Ожидаемое / Фактическое
|
||||
-
|
||||
|
||||
### Окружение
|
||||
- Ветка: main/dev
|
||||
- Коммит:
|
||||
- ОС:
|
||||
- Python:
|
||||
|
||||
### Дополнительно (для фич)
|
||||
Опишите мотивацию, альтернативы.
|
||||
|
||||
### Примечание по безопасности
|
||||
Не публикуйте PoC публично.
|
||||
Для сообщений об уязвимостях свяжитесь напрямую: [@solonet_sup](https://t.me/solonet_sup).
|
||||
@@ -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 |
@@ -0,0 +1,21 @@
|
||||
### Описание изменений
|
||||
Опишите, что изменилось и зачем.
|
||||
|
||||
### Ветка назначения
|
||||
- [ ] dev (основная ветка разработки)
|
||||
- [ ] main (только для критических исправлений безопасности)
|
||||
|
||||
### Тип изменений
|
||||
- [ ] Новая функциональность
|
||||
- [ ] Исправление ошибки
|
||||
- [ ] Рефакторинг
|
||||
- [ ] Документация
|
||||
- [ ] Другое
|
||||
|
||||
### Чек-лист
|
||||
- [ ] Изменения протестированы локально
|
||||
- [ ] Документация обновлена (если требуется)
|
||||
- [ ] Нет лишних изменений в коде
|
||||
|
||||
### Связанные задачи
|
||||
Укажите номер issue, если PR его закрывает (например: Closes #123).
|
||||
+8
-3
@@ -7,6 +7,7 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
alembic/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
@@ -28,6 +29,7 @@ env/
|
||||
# Sensitive configuration files
|
||||
config.py
|
||||
config.ini
|
||||
alembic.ini
|
||||
.env
|
||||
|
||||
# Backup files
|
||||
@@ -51,8 +53,11 @@ Thumbs.db
|
||||
|
||||
nginx.conf
|
||||
scripts
|
||||
models.py
|
||||
Dockerfile
|
||||
.csv
|
||||
|
||||
setup.py
|
||||
/logs
|
||||
setup.py
|
||||
.ruff_cache
|
||||
.github/workflows/
|
||||
modules/
|
||||
storage/
|
||||
@@ -0,0 +1,25 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Общие правила
|
||||
|
||||
- Уважайте друг друга. Недопустимы оскорбления, дискриминация или токсичное поведение.
|
||||
- Обсуждения должны оставаться по теме и в конструктивной форме.
|
||||
- Личные и конфиденциальные данные публиковать запрещено.
|
||||
- Нарушения могут привести к предупреждению, ограничению участия или блокировке по усмотрению мейнтейнеров.
|
||||
|
||||
## Участники GitHub
|
||||
|
||||
- Могут предлагать изменения через **Issues** и **Pull Requests**.
|
||||
- Их вклад рассматривается и принимается по решению мейнтейнеров.
|
||||
- Дискуссии ограничены рамками кода и технических вопросов.
|
||||
|
||||
## Участники сообщества в Telegram
|
||||
|
||||
- Имеют приоритетное взаимодействие с разработчиком.
|
||||
- Могут обсуждать проект шире: идеи, улучшения, новые направления.
|
||||
- Влияние на развитие кода выше, чем у участников извне.
|
||||
- Общение остаётся в рамках уважения и конструктивности.
|
||||
|
||||
## Контакты
|
||||
|
||||
По вопросам поведения используйте Issues с меткой `conduct` или обращайтесь через сообщество в Telegram.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Contributing Guidelines
|
||||
|
||||
Спасибо, что хотите внести вклад в развитие проекта!
|
||||
|
||||
## Как помочь
|
||||
|
||||
- Создавайте **Issues** для конкретных целей или описания найденных багов.
|
||||
- Отправляйте **Pull Requests** с улучшениями и исправлениями. Каждое предложение будет рассмотрено, и мы примем решение о включении.
|
||||
- Делайте **форки** репозитория и дорабатывайте код — лучшие практики мы обязательно объединим в общее дело.
|
||||
|
||||
## Стиль кода
|
||||
|
||||
- Соблюдайте стандарт **PEP8**.
|
||||
- Для автоматической проверки можно использовать **ruff**.
|
||||
|
||||
## Правила коммитов
|
||||
|
||||
- Сообщения должны быть осмысленными и отражать суть изменений.
|
||||
- Если в одном коммите несколько изменений, перечисляйте их через `/`.
|
||||
|
||||
## Лицензия
|
||||
|
||||
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.
|
||||
|
||||
Внося изменения, вы соглашаетесь, что ваш вклад распространяется на условиях этой лицензии. Перепродажа или коммерческое распространение этого кода без явного письменного согласия правообладателя не допускаются.
|
||||
---
|
||||
Ваш вклад помогает проекту расти и становиться лучше.
|
||||
@@ -1,6 +1,10 @@
|
||||
formatting:
|
||||
@echo "Running Ruff..." && ruff check . --fix
|
||||
@echo "Running Ruff format..." && ruff format .
|
||||
format:
|
||||
@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 .
|
||||
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
|
||||
format-payments:
|
||||
@echo "Running Ruff format ONLY on handlers/payments..." && ruff format handlers/payments --config pyproject.toml
|
||||
@echo "Running Ruff check ONLY on handlers/payments..." && ruff check handlers/payments --config pyproject.toml --fix
|
||||
|
||||
@@ -1,154 +1,211 @@
|
||||
# 🚀 SoloBot
|
||||
<div align="center" style="max-width:980px;margin:0 auto;padding:0 12px">
|
||||
|
||||
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
|
||||

|
||||
|
||||
Три версии — море возможностей:
|
||||
<h1 style="margin:0;font-weight:900;letter-spacing:.3px">SoloBot</h1>
|
||||
<p style="margin:6px 0 0;font-size:16px;opacity:.9"><b>Идеальный помощник для 3x-UI и Remnawave (VLESS)</b></p>
|
||||
|
||||
- [v1.4](https://github.com/Vladless/Solo_bot/releases/tag/v1.4) — бот для продажи ключей VLESS:
|
||||
|
||||

|
||||
<p style="margin:16px 0 0">
|
||||
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/➡_Website-visit-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Website">
|
||||
</a>
|
||||
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/Try_in_Telegram-demo-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Telegram">
|
||||
</a>
|
||||
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/Features-overview-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=readthedocs&logoColor=ff6a00" alt="Features">
|
||||
</a>
|
||||
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/Releases-history-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Releases">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
- [v2.3.1](https://github.com/Vladless/Solo_bot/releases/tag/v2.3.1) — стабильная версия подписок вместо ключей с
|
||||
кнопками автодобавления в приложение:
|
||||
<p style="margin:10px 0 0">
|
||||
<a href="LICENSE" target="_blank">
|
||||
<img height="32" src="https://img.shields.io/badge/License-CC_BY--NC_4.0-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=creative-commons&logoColor=ff6a00" alt="License">
|
||||
</a>
|
||||
</p>
|
||||
|
||||

|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
<p><b>Многофункциональный бот, кастомизируемый под ваш бренд.</b></p>
|
||||
</div>
|
||||
|
||||
- [v3.1](https://github.com/Vladless/Solo_bot/releases/tag/v3.1) — версия бота со значительным расширением возможностей.
|
||||
|
||||
[Последние актуальные релизы.](https://github.com/Vladless/Solo_bot/releases)
|
||||
Если вам не хватает функций — направьте их в [issue](https://github.com/Vladless/Solo_bot/issues), мы реализуем.
|
||||
<h2 align="center">Основные возможности</h2>
|
||||
|
||||
## 📋 Оглавление
|
||||
<table align="center" style="max-width:980px;width:100%">
|
||||
<tr>
|
||||
<th align="left" style="width:30%">📌 Раздел</th>
|
||||
<th align="left">💡 Возможности</th>
|
||||
</tr>
|
||||
|
||||
1. [Описание](#-описание)
|
||||
2. [Стек технологий](#-стек-технологий)
|
||||
3. [Установка](#-установка)
|
||||
4. [Конфигурация](#-конфигурация)
|
||||
5. [Запуск](#-запуск)
|
||||
6. [Контакты](#-solobot-в-telegram-и-полная-версия)
|
||||
<tr>
|
||||
<td><b>Мультипанель</b></td>
|
||||
<td>3x-ui<br>Remnawave<br>Совместимый режим</td>
|
||||
</tr>
|
||||
|
||||
---
|
||||
<tr>
|
||||
<td><b>Подписки</b></td>
|
||||
<td>Произвольный срок и триал<br>Продление по тарифу<br>Happ / Hiddify / v2RayTun<br>Кастом заголовков</td>
|
||||
</tr>
|
||||
|
||||
## 📖 Описание
|
||||
<tr>
|
||||
<td><b>Тарификация</b></td>
|
||||
<td>Лимиты устройств<br>Лимиты трафика<br>Лимиты серверов в подписках</td>
|
||||
</tr>
|
||||
|
||||
SoloBot реализует множество функций, включая:
|
||||
<tr>
|
||||
<td><b>Клиент</b></td>
|
||||
<td>Ключ / сервер / TTL<br>Продление / удаление / начисление дней / отключение<br>Смена локации<br>Несколько устройств</td>
|
||||
</tr>
|
||||
|
||||
- **Выдача подписок на различные периоды** (1 месяц, 3 месяца, полгода, год).
|
||||
- **Использование тестового периода подписки.**
|
||||
- **Продление ключей** на указанные периоды.
|
||||
- **Полный контроль** клиента над своими ключами:
|
||||
- **Просмотр** информации о ключе (сервер, оставшееся время, сам ключ).
|
||||
- **Продление** ключей.
|
||||
- **Удаление** ключей.
|
||||
- **Смена локации** (перемещение ключа между серверами).
|
||||
- **Поддержка нескольких ключей и устройств** для одного клиента.
|
||||
- **Реферальная программа** с уникальной ссылкой для приглашений.
|
||||
- Доступ к **инструкциям** для подключения к сервису.
|
||||
- Пополнение баланса:
|
||||
* **Юкасса** (Самозанятость и ИП)
|
||||
* **Юмани** (Физические Лица) ([TrackLine](https://github.com/TrackLine))
|
||||
* **FreeKassa** (Физические Лица)
|
||||
* **Robokassa** (Физические Лица)
|
||||
* **Cryptobot** (Криптовалюта) ([izzzzzi](https://github.com/izzzzzi))
|
||||
* **Звезды Telegram**
|
||||
- Периодические **бэкапы базы данных клиентов**.
|
||||
- Уведомления:
|
||||
- Произвольные сообщения через панель администратора.
|
||||
- Уведомления о неиспользованных пробниках.
|
||||
- Уведомления об истекающих ключах (за сутки, за 6 часов и в момент истечения).
|
||||
- **Чат поддержки** и канал для связи.
|
||||
- **Автоматическое продление ключа** при наличии достаточного баланса.
|
||||
- **Удобная панель администратора прямо в боте**
|
||||
- Серверы:
|
||||
- **Мультисерверность** — добавление сервера прямо в админке
|
||||
- **Проверка на доступность** — бот следит за серверами и их откликом
|
||||
- **Балансировка** — бот выдает подписку на наименее загруженный сервер
|
||||
<tr>
|
||||
<td><b>Поддержка роутеров</b></td>
|
||||
<td>Выдача VLESS подписки за отдельную цену</td>
|
||||
</tr>
|
||||
|
||||
---
|
||||
<tr>
|
||||
<td><b>Рефералы</b></td>
|
||||
<td>Уникальные ссылки<br>Инлайн и обычные сообщения<br>% или фикс</td>
|
||||
</tr>
|
||||
|
||||
## 💻 Стек технологий
|
||||
<tr>
|
||||
<td><b>UTM-аналитика</b></td>
|
||||
<td>Трекинг переходов<br>Привязка к рефералам / купонам / триалам<br>Конверсии<br>Просмотр в админке</td>
|
||||
</tr>
|
||||
|
||||
Основные технологии, используемые в проекте:
|
||||
<tr>
|
||||
<td><b>Оплата</b></td>
|
||||
<td>YooKassa (ИП/Самозанятые)<br>YooMoney (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Robokassa (ИП)<br>Telegram Stars<br>Heleket (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>Wata (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Kassai (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>И другие <a href="https://pocomacho.ru/solonetbot/modules/">см. Модули</a></td>
|
||||
</tr>
|
||||
|
||||
- **Python** версии 3.10 (3.12, для последних версий).
|
||||
- **PostgreSQL** — для хранения данных.
|
||||
- **aiogram** — для взаимодействия с Telegram API.
|
||||
- **aiohttp** — для API запросов к панелям.
|
||||
<tr>
|
||||
<td><b>Валютность</b></td>
|
||||
<td>Оплата в разных валютах</td>
|
||||
</tr>
|
||||
|
||||
Проект полностью асинхронный, что обеспечивает высокую скорость работы.
|
||||
<tr>
|
||||
<td><b>Надёжность</b></td>
|
||||
<td>Бэкапы<br>Смена домена<br>Проверка доступности<br>Уведомления об аптайме</td>
|
||||
</tr>
|
||||
|
||||
---
|
||||
<tr>
|
||||
<td><b>Уведомления</b></td>
|
||||
<td>О скором окончании подписки<br>О непотраченном трафике</td>
|
||||
</tr>
|
||||
|
||||
## ⚙️ Установка
|
||||
<tr>
|
||||
<td><b>Рассылки</b></td>
|
||||
<td>По заданным группам<br>С картинками или видео</td>
|
||||
</tr>
|
||||
|
||||
### 1️⃣ Шаг 1: Клонирование репозитория
|
||||
<tr>
|
||||
<td><b>Серверы</b></td>
|
||||
<td>Мультисерверность<br>Одна локация или подписка<br>Автопроверка и балансировка<br>Синхронизация и лимиты<br>Вкл/выкл</td>
|
||||
</tr>
|
||||
|
||||
Клонируйте репозиторий и перейдите в его директорию:
|
||||
<tr>
|
||||
<td><b>Воронка продаж</b></td>
|
||||
<td>Доп. дни к триалу: не взял триал → оффер → активация<br>Горячие лиды: платил → перестал → таргет/КП → возврат</td>
|
||||
</tr>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Vladless/Solo_bot.git
|
||||
cd solo_bot
|
||||
```
|
||||
<tr>
|
||||
<td><b>Админка</b></td>
|
||||
<td>Поиск TG/username/ключ/email<br>Баланс / подписки / заморозки<br>Перезагрузка<br>Бан-лист · купоны · UTM · статистика<br>Синхронизация / импорт из панели</td>
|
||||
</tr>
|
||||
|
||||
### 2️⃣ Шаг 2: Создание и подключение виртуального окружения
|
||||
<tr>
|
||||
<td><b>Модульность</b></td>
|
||||
<td>Подключаемые модули<br>Вкл/выкл и перезапуск из админки<br>Обновления и версии<br>Возможность дорабатывать код не теряя обновления</td>
|
||||
</tr>
|
||||
|
||||
Создайте виртуальное окружение **Python** и перейдите в сессию:
|
||||
<tr>
|
||||
<td><b>Кастомизация</b></td>
|
||||
<td>Гибкая настройка функций и логики<br>Возможность отключения отдельных функций</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
```
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
<div style="height:22px"></div>
|
||||
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
|
||||
<div style="height:22px"></div>
|
||||
|
||||
### 3️⃣ Шаг 3: Установка зависимостей
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
|
||||
Установите зависимости:
|
||||
<h2 align="center">Обновления</h2>
|
||||
<p align="center">Регулярные релизы. Обновление через CLI. Ветки: <code>main</code> — стабильные версии, <code>dev</code> — бета.</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/➡_Все_версии-смотреть-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Все версии">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
<div style="height:22px"></div>
|
||||
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
|
||||
<div style="height:22px"></div>
|
||||
|
||||
### 🛠️ Конфигурация
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
|
||||
Для правильной работы вам нужно:
|
||||
<h2 align="center">Гайды и расширения</h2>
|
||||
<p align="center" style="max-width:900px;margin:8px auto 14px">
|
||||
На сайте всегда лежат актуальные файлы для запуска разных версий бота. Есть собственное Wiki с пошаговыми инструкциями по установке и обновлениям. Доступна система модулей: бот легко дополняется готовыми расширениями и вашим кастомным функционалом.
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/7d0a954d-41b7-4d7c-8618-aee1f4459792" alt="Guides" width="100%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
|
||||
<img height="44" src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Site">
|
||||
</a>
|
||||
<a href="https://pocomacho.ru/solonetbot/reviews/" target="_blank">
|
||||
<img height="44" src="https://img.shields.io/badge/Отзывы-на_сайте-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=discourse&logoColor=ff6a00" alt="Reviews">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
* Установить и запустить **PostgreSQL**, создать пользователя для работы с базой данных и выдать ему права
|
||||
* Настроить ваш сервер для работы с ботом, выпустить SSL сертификат для домена
|
||||
* Настроить вебхуки и указать пути до них через любой веб-сервер, например **nginx**
|
||||
<div style="height:22px"></div>
|
||||
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
|
||||
<div style="height:22px"></div>
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
|
||||
* Создать файл config.py в корневой папке проекта с вашими данными:
|
||||
<h2 align="center">Полная версия</h2>
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30" alt="Community" width="100%">
|
||||
</p>
|
||||
<p align="center" style="max-width:900px;margin:8px auto 14px">
|
||||
SoloBot уже помог более чем 500 пользователям из сообщества. Присоединяйтесь: попробуйте бота или напишите в поддержку, чтобы получить доступ и консультацию.
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/Попробовать_бота-открыть_Telegram-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Try bot">
|
||||
</a>
|
||||
<a href="https://t.me/solonet_sup" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/Написать_в_поддержку-получить_доступ-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Support">
|
||||
</a>
|
||||
</p>
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
|
||||
```
|
||||
API_TOKEN = "токен вашего бота телеграм"
|
||||
<h2 align="center">Лицензия</h2>
|
||||
<table align="center" style="max-width:900px;width:100%">
|
||||
<tr>
|
||||
<td style="width:8%;text-align:center">❗</td>
|
||||
<td><b>Этот проект распространяется по лицензии <a href="LICENSE">CC BY-NC 4.0</a></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align:center">⛔</td>
|
||||
<td><b>Перепродажа кода запрещена.</b> Нельзя продавать или перепродавать код без разрешения автора.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align:center">✅</td>
|
||||
<td><b>Для личного использования.</b> Код можно использовать и модифицировать для личных проектов.</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
|
||||
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/"
|
||||
```
|
||||
<h2 align="center">Участники</h2>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" alt="Contributors">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Мы высылаем детальный гайд и недостающие файлы в поддержке бота!**
|
||||
|
||||
### 🚀 Запуск
|
||||
|
||||
Введите команду из виртуального окружения `venv/bin/activate`
|
||||
|
||||
```
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
### 🔗 SoloBot в Telegram и Полная версия
|
||||
|
||||
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
|
||||
|
||||
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
|
||||

|
||||
|
||||
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
|
||||
|
||||
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
|
||||
и пароль от сайта, а также задать необходимые вопросы!
|
||||
|
||||
## Права на использование
|
||||
|
||||
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Security Policy
|
||||
|
||||
## Поддерживаемые ветки
|
||||
- Рассматриваются только `main` (последняя стабильная) и `dev` (текущая разработка).
|
||||
- Другие ветки и теги не поддерживаются.
|
||||
|
||||
## Как сообщить об уязвимости
|
||||
- Не создавайте публичные Issues или Pull Requests.
|
||||
- Сообщите напрямую владельцу: [@solonet_sup](https://t.me/solonet_sup).
|
||||
- Также можно уведомить через сообщество в Telegram (доступно после вступления).
|
||||
- Укажите: ветку (`main`/`dev`), версию/коммит, шаги воспроизведения, ожидаемое и фактическое поведение, возможное влияние, логи или скриншоты.
|
||||
|
||||
## Процесс
|
||||
1. Подтверждаем получение отчёта.
|
||||
2. Анализируем и определяем критичность.
|
||||
3. Исправления выпускаются в `dev`.
|
||||
4. Если уязвимость критическая — фикс публикуется напрямую в `main`.
|
||||
5. После релиза уведомляем сообщество и при необходимости публикуем рекомендации по обновлению.
|
||||
|
||||
## Область действия
|
||||
- Бот (aiogram), веб-админка (flask), внутренние модули/плагины и API проекта.
|
||||
- Вне области: сторонние сервисы и библиотеки (платёжные провайдеры, Telegram, панели и т. п.). Для них обращайтесь к поставщику напрямую.
|
||||
|
||||
## Ответственное раскрытие
|
||||
- Просим не публиковать детали до выхода исправления.
|
||||
- Тесты проводите только на своих данных и аккаунтах.
|
||||
- Атаки на инфраструктуру или пользователей строго запрещены.
|
||||
|
||||
## Лучшие практики при отчёте
|
||||
- Минимальный PoC без риска для данных и доступности.
|
||||
- Чёткие и воспроизводимые шаги.
|
||||
- Возможные предложения по снижению риска до исправления.
|
||||
@@ -0,0 +1,32 @@
|
||||
import hashlib
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, Header, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import async_session_maker
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
async def verify_admin_token(
|
||||
admin_id: int = Query(..., alias="tg_id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Admin:
|
||||
hashed = hash_token(token)
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
|
||||
admin = result.scalar_one_or_none()
|
||||
if not admin:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return admin
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
from fastapi import FastAPI
|
||||
from api.routes import users, keys, coupons, servers, tariffs, gifts, referrals, misc
|
||||
|
||||
app = FastAPI(
|
||||
title="SoloBot API (preAlpha)",
|
||||
version="0.2.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json"
|
||||
)
|
||||
|
||||
|
||||
app.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||
app.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
|
||||
app.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
|
||||
app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
app.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
app.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
app.include_router(misc.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "Welcome to SoloBot API. Docs: /api/docs"}
|
||||
@@ -0,0 +1,145 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
def _cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
column_type = type(field.property.columns[0].type).__name__
|
||||
if column_type in ("Integer", "BigInteger"):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
*,
|
||||
model: type,
|
||||
schema_response: type,
|
||||
schema_create: type,
|
||||
schema_update: type,
|
||||
identifier_field: str = "tg_id",
|
||||
parameter_name: str = "tg_id",
|
||||
extra_get_by_email: bool = False,
|
||||
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
if "get_all" in enabled_methods:
|
||||
|
||||
@router.get("/", response_model=list[schema_response])
|
||||
async def get_all(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model))
|
||||
return result.scalars().all()
|
||||
|
||||
if "get_by_email" in enabled_methods and extra_get_by_email:
|
||||
|
||||
@router.get("/by_email", response_model=schema_response)
|
||||
async def get_by_email(
|
||||
email: str = Query(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model).where(model.email == email))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found by email")
|
||||
return obj
|
||||
|
||||
if "get_one" in enabled_methods:
|
||||
|
||||
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def get_one(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
return obj
|
||||
|
||||
if "get_all_by_field" in enabled_methods:
|
||||
|
||||
@router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response])
|
||||
async def get_all_by_field(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
return objs
|
||||
|
||||
if "create" in enabled_methods:
|
||||
|
||||
@router.post("/", response_model=schema_response)
|
||||
async def create(
|
||||
payload: schema_create, # type: ignore
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
data = payload.dict(exclude_unset=True)
|
||||
if "days" in data and data["days"] == 0:
|
||||
data["days"] = None
|
||||
obj = model(**data)
|
||||
session.add(obj)
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
|
||||
if "update" in enabled_methods:
|
||||
|
||||
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def update(
|
||||
payload: schema_update, # type: ignore
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
for k, v in payload.dict(exclude_unset=True).items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
|
||||
if "delete" in enabled_methods:
|
||||
|
||||
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
|
||||
async def delete(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = _cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
return {"detail": f"{model.__name__} deleted"}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from database.models import Coupon
|
||||
|
||||
|
||||
router: APIRouter = generate_crud_router(
|
||||
model=Coupon,
|
||||
schema_response=CouponResponse,
|
||||
schema_create=CouponBase,
|
||||
schema_update=CouponUpdate,
|
||||
identifier_field="code",
|
||||
parameter_name="code",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from database.models import Admin, Gift, GiftUsage
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
gift_router = generate_crud_router(
|
||||
model=Gift,
|
||||
schema_response=GiftResponse,
|
||||
schema_create=GiftBase,
|
||||
schema_update=GiftUpdate,
|
||||
identifier_field="gift_id",
|
||||
parameter_name="gift_id",
|
||||
enabled_methods=["get_all", "get_one", "create", "update"],
|
||||
)
|
||||
router.include_router(gift_router, prefix="", tags=["Gifts"])
|
||||
|
||||
|
||||
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
|
||||
async def get_gifts_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
|
||||
gifts = result.scalars().all()
|
||||
if not gifts:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
return gifts
|
||||
|
||||
|
||||
gift_usage_router = generate_crud_router(
|
||||
model=GiftUsage,
|
||||
schema_response=GiftUsageResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="gift_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
)
|
||||
router.include_router(gift_usage_router, prefix="/usages", tags=["GiftUsages"])
|
||||
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts"])
|
||||
|
||||
|
||||
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
|
||||
async def delete_gift_with_usages(
|
||||
gift_id: str = Path(..., description="ID подарка"),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
|
||||
gift = result.scalar_one_or_none()
|
||||
if not gift:
|
||||
raise HTTPException(status_code=404, detail="Gift not found")
|
||||
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.delete(gift)
|
||||
await session.commit()
|
||||
return {"message": "Подарок и связанные использования удалены"}
|
||||
@@ -0,0 +1,141 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Body, Depends, HTTPException, Path, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from database.models import Admin, Key, Tariff
|
||||
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Key,
|
||||
schema_response=KeyResponse,
|
||||
schema_create=KeyBase,
|
||||
schema_update=KeyUpdate,
|
||||
identifier_field="tg_id",
|
||||
extra_get_by_email=True,
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/by_email/{email}", response_model=dict)
|
||||
async def delete_key_by_email(
|
||||
email: str = Path(..., description="Email клиента"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
result = await session.execute(select(Key).where(Key.email == email))
|
||||
db_key = result.scalar_one_or_none()
|
||||
|
||||
if not db_key:
|
||||
raise HTTPException(status_code=404, detail="Ключ не найден")
|
||||
|
||||
try:
|
||||
await delete_key_from_cluster(
|
||||
session=session,
|
||||
email=db_key.email,
|
||||
client_id=db_key.client_id,
|
||||
cluster_id=db_key.server_id,
|
||||
)
|
||||
await session.delete(db_key)
|
||||
await session.commit()
|
||||
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
|
||||
return {"message": "Ключ успешно удалён"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при удалении ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
|
||||
|
||||
|
||||
@router.get("/routers/{tg_id}", response_model=list[KeyResponse])
|
||||
async def get_router_keys_by_tg_id(
|
||||
tg_id: int = Path(..., description="Telegram ID пользователя"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers"))
|
||||
tariff_ids = [row[0] for row in tariffs_result.all()]
|
||||
if not tariff_ids:
|
||||
return []
|
||||
|
||||
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
|
||||
keys = keys_result.scalars().all()
|
||||
return keys
|
||||
|
||||
|
||||
@router.patch("/edit/by_email/{email}", response_model=KeyResponse)
|
||||
async def edit_key_by_email(
|
||||
email: str = Path(..., description="Email клиента"),
|
||||
key_update: KeyUpdate = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
result = await session.execute(select(Key).where(Key.email == email))
|
||||
db_key = result.scalar_one_or_none()
|
||||
if not db_key:
|
||||
raise HTTPException(status_code=404, detail="Ключ не найден")
|
||||
|
||||
for field, value in key_update.dict(exclude_unset=True).items():
|
||||
if field == "expiry_time" and value is not None:
|
||||
if isinstance(value, int):
|
||||
ms = value
|
||||
elif isinstance(value, datetime):
|
||||
ms = int(value.timestamp() * 1000)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Некорректный формат времени")
|
||||
setattr(db_key, field, ms)
|
||||
else:
|
||||
setattr(db_key, field, value)
|
||||
|
||||
try:
|
||||
new_expiry_time = db_key.expiry_time
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=db_key.server_id,
|
||||
email=db_key.email,
|
||||
client_id=db_key.client_id,
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=getattr(db_key, "traffic_limit", None),
|
||||
session=session,
|
||||
hwid_device_limit=getattr(db_key, "device_limit", None),
|
||||
reset_traffic=True,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
|
||||
return db_key
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при обновлении ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа")
|
||||
|
||||
|
||||
@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_key_api(
|
||||
payload: KeyCreateRequest = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
try:
|
||||
await create_key_on_cluster(
|
||||
cluster_id=payload.cluster_id,
|
||||
tg_id=payload.tg_id,
|
||||
client_id=payload.client_id,
|
||||
email=payload.email or f"{payload.tg_id}_key",
|
||||
expiry_timestamp=payload.expiry_timestamp,
|
||||
plan=payload.tariff_id,
|
||||
session=session,
|
||||
remnawave_link=payload.remnawave_link,
|
||||
hwid_limit=payload.hwid_limit,
|
||||
traffic_limit_bytes=payload.traffic_limit_bytes,
|
||||
is_trial=payload.is_trial or False,
|
||||
)
|
||||
return {"message": "Ключ успешно создан"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при создании ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
|
||||
@@ -0,0 +1,171 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import (
|
||||
BlockedUserResponse,
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
PaymentResponse,
|
||||
ReferralResponse,
|
||||
TemporaryDataResponse,
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from database import get_tracking_source_stats
|
||||
from database.models import (
|
||||
Admin,
|
||||
BlockedUser,
|
||||
ManualBan,
|
||||
Notification,
|
||||
Payment,
|
||||
Referral,
|
||||
TemporaryData,
|
||||
TrackingSource,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=Payment,
|
||||
schema_response=PaymentResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/payments",
|
||||
tags=["Payments"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/payments/by_tg_id/{tg_id}", response_model=list[PaymentResponse], tags=["Payments"])
|
||||
async def get_payments_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
|
||||
payments = result.scalars().all()
|
||||
if not payments:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
return payments
|
||||
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=Referral,
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referred_tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/referrals",
|
||||
tags=["Referrals"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=Notification,
|
||||
schema_response=NotificationResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
tags=["Notifications"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=ManualBan,
|
||||
schema_response=ManualBanResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/manual-bans",
|
||||
tags=["Bans"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=BlockedUser,
|
||||
schema_response=BlockedUserResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/blocked-users",
|
||||
tags=["Bans"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=TemporaryData,
|
||||
schema_response=TemporaryDataResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/temporary-data",
|
||||
tags=["TemporaryData"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=TrackingSource,
|
||||
schema_response=TrackingSourceResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="id",
|
||||
enabled_methods=["get_all", "delete"],
|
||||
),
|
||||
prefix="/tracking-sources",
|
||||
tags=["TrackingSources"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_admin_token)]
|
||||
)
|
||||
async def get_tracking_source_with_stats(
|
||||
code: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Tracking source not found")
|
||||
|
||||
stats = await get_tracking_source_stats(session, code)
|
||||
|
||||
return TrackingSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
code=source.code,
|
||||
type=source.type,
|
||||
created_by=source.created_by,
|
||||
created_at=source.created_at,
|
||||
registrations=(stats["registrations"] if stats else 0),
|
||||
trials=(stats["trials"] if stats else 0),
|
||||
payments=(stats["payments"] if stats else 0),
|
||||
total_amount=(float(stats["total_amount"]) if stats else 0.0),
|
||||
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import ReferralResponse
|
||||
from database.models import Admin, Referral
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Referral,
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referrer_tg_id",
|
||||
enabled_methods=["get_all", "get_one", "get_all_by_field"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/one")
|
||||
async def delete_one_referral(
|
||||
referrer_tg_id: int = Query(..., description="ID пригласившего"),
|
||||
referred_tg_id: int = Query(..., description="ID приглашённого"),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Referral not found")
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
return {"status": "deleted_one"}
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import ServerBase, ServerResponse, ServerUpdate
|
||||
from database.models import Server
|
||||
|
||||
|
||||
router: APIRouter = generate_crud_router(
|
||||
model=Server,
|
||||
schema_response=ServerResponse,
|
||||
schema_create=ServerBase,
|
||||
schema_update=ServerUpdate,
|
||||
identifier_field="server_name",
|
||||
parameter_name="server_name",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from database.models import Tariff
|
||||
|
||||
|
||||
router: APIRouter = generate_crud_router(
|
||||
model=Tariff,
|
||||
schema_response=TariffResponse,
|
||||
schema_create=TariffBase,
|
||||
schema_update=TariffUpdate,
|
||||
identifier_field="name",
|
||||
parameter_name="name",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas.users import UserBase, UserResponse, UserUpdate
|
||||
from database import delete_user_data, get_servers
|
||||
from database.models import Key, User
|
||||
from handlers.keys.operations import delete_key_from_cluster
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=User,
|
||||
schema_response=UserResponse,
|
||||
schema_create=UserBase,
|
||||
schema_update=UserUpdate,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tg_id}", response_model=dict)
|
||||
async def delete_user(
|
||||
tg_id: int = Path(..., description="Telegram ID пользователя"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
|
||||
key_records = result.all()
|
||||
|
||||
async def delete_keys_from_servers():
|
||||
try:
|
||||
servers = await get_servers(session=session)
|
||||
tasks = []
|
||||
for email, client_id in key_records:
|
||||
for cluster_id in servers:
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id, session))
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
await delete_keys_from_servers()
|
||||
await delete_user_data(session, tg_id)
|
||||
|
||||
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
|
||||
@@ -0,0 +1,15 @@
|
||||
from .coupons import CouponBase, CouponResponse, CouponUpdate, CouponUsageResponse
|
||||
from .gifts import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from .keys import KeyDetailsResponse, KeyResponse
|
||||
from .misc import (
|
||||
BlockedUserResponse,
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
PaymentResponse,
|
||||
TemporaryDataResponse,
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from .referrals import ReferralResponse
|
||||
from .servers import ServerBase, ServerResponse, ServerUpdate
|
||||
from .tariffs import TariffBase, TariffResponse, TariffUpdate
|
||||
from .users import UserBase, UserResponse, UserUpdate
|
||||
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class CouponBase(BaseModel):
|
||||
code: str
|
||||
amount: int | None = Field(default=None)
|
||||
usage_limit: int
|
||||
usage_count: int = 0
|
||||
is_used: bool = False
|
||||
days: int | None = Field(default=None)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_exactly_one_of_amount_or_days(self) -> "CouponBase":
|
||||
has_amount = self.amount not in (None, 0)
|
||||
has_days = self.days is not None
|
||||
|
||||
if has_amount and has_days:
|
||||
raise ValueError("Coupon must have exactly one of: 'amount' or 'days'")
|
||||
if not has_amount and not has_days:
|
||||
raise ValueError("Coupon must have exactly one of: 'amount' or 'days'")
|
||||
return self
|
||||
|
||||
|
||||
class CouponResponse(CouponBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CouponUpdate(BaseModel):
|
||||
code: str | None = None
|
||||
amount: int | None = None
|
||||
usage_limit: int | None = None
|
||||
usage_count: int | None = None
|
||||
is_used: bool | None = None
|
||||
days: int | None = Field(default=None)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_amount_or_days(self) -> "CouponUpdate":
|
||||
if self.amount is None and self.days is None:
|
||||
return self
|
||||
if self.amount is not None and self.days is not None:
|
||||
raise ValueError("Specify only one of: 'amount' or 'days'")
|
||||
return self
|
||||
|
||||
|
||||
class CouponUsageResponse(BaseModel):
|
||||
coupon_id: int
|
||||
user_id: int
|
||||
used_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
selected_months: int | None = None
|
||||
expiry_time: datetime
|
||||
gift_link: str
|
||||
is_used: bool = False
|
||||
is_unlimited: bool | None = False
|
||||
max_usages: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
class GiftResponse(GiftBase):
|
||||
gift_id: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftUsageResponse(BaseModel):
|
||||
gift_id: str
|
||||
tg_id: int
|
||||
used_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftUpdate(BaseModel):
|
||||
recipient_tg_id: int | None = None
|
||||
selected_months: int | None = None
|
||||
expiry_time: datetime | None = None
|
||||
gift_link: str | None = None
|
||||
is_used: bool | None = None
|
||||
is_unlimited: bool | None = None
|
||||
max_usages: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,76 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KeyBase(BaseModel):
|
||||
tg_id: int
|
||||
client_id: str
|
||||
email: str | None = None
|
||||
created_at: int | None = None
|
||||
expiry_time: int
|
||||
key: str | None = None
|
||||
server_id: str | None = None
|
||||
remnawave_link: str | None = None
|
||||
tariff_id: int | None = None
|
||||
is_frozen: bool | None = False
|
||||
alias: str | None = None
|
||||
notified: bool | None = False
|
||||
notified_24h: bool | None = False
|
||||
|
||||
|
||||
class KeyResponse(KeyBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KeyDetailsResponse(BaseModel):
|
||||
key: str | None
|
||||
remnawave_link: str | None
|
||||
server_id: str | None
|
||||
created_at: int | None
|
||||
expiry_time: int | None
|
||||
client_id: str
|
||||
tg_id: int
|
||||
email: str | None
|
||||
is_frozen: bool
|
||||
balance: float
|
||||
alias: str | None
|
||||
expiry_date: str
|
||||
days_left_message: str
|
||||
link: str | None
|
||||
cluster_name: str | None
|
||||
location_name: str | None
|
||||
tariff_id: int | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KeyUpdate(BaseModel):
|
||||
email: str | None = None
|
||||
expiry_time: int | None = None
|
||||
key: str | None = None
|
||||
server_id: str | None = None
|
||||
remnawave_link: str | None = None
|
||||
tariff_id: int | None = None
|
||||
is_frozen: bool | None = None
|
||||
alias: str | None = None
|
||||
notified: bool | None = None
|
||||
notified_24h: bool | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KeyCreateRequest(BaseModel):
|
||||
tg_id: int = Field(..., description="Telegram ID пользователя")
|
||||
cluster_id: str = Field(..., description="Имя кластера или сервера")
|
||||
tariff_id: int = Field(..., description="ID тарифа из базы данных")
|
||||
client_id: str = Field(..., description="UUID клиента (уникальный)")
|
||||
expiry_timestamp: int = Field(..., description="Срок окончания в миллисекундах")
|
||||
|
||||
email: str | None = Field(None, description="Условное имя подписки")
|
||||
alias: str | None = Field(None, description="пользовательское имя")
|
||||
remnawave_link: str | None = Field(None, description="Ссылка на подписку Remnawave")
|
||||
hwid_limit: int | None = Field(None, description="Ограничение по HWID")
|
||||
traffic_limit_bytes: int | None = Field(None, description="Ограничение трафика в байтах")
|
||||
is_trial: bool | None = Field(False, description="Флаг триального ключа")
|
||||
@@ -0,0 +1,122 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PaymentBase(BaseModel):
|
||||
tg_id: int
|
||||
amount: float
|
||||
payment_system: str
|
||||
status: str
|
||||
|
||||
|
||||
class PaymentResponse(PaymentBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
tg_id: int
|
||||
notification_type: str
|
||||
last_notification_time: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
selected_months: int
|
||||
expiry_time: datetime
|
||||
gift_link: str
|
||||
is_used: bool = False
|
||||
is_unlimited: bool = False
|
||||
max_usages: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
class GiftResponse(GiftBase):
|
||||
gift_id: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftUsageResponse(BaseModel):
|
||||
gift_id: str
|
||||
tg_id: int
|
||||
used_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ManualBanResponse(BaseModel):
|
||||
tg_id: int
|
||||
banned_at: datetime
|
||||
reason: str
|
||||
banned_by: int
|
||||
until: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TemporaryDataResponse(BaseModel):
|
||||
tg_id: int
|
||||
state: str
|
||||
data: dict
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BlockedUserResponse(BaseModel):
|
||||
tg_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MonthlyStats(BaseModel):
|
||||
month: str
|
||||
registrations: int
|
||||
trials: int
|
||||
new_purchases_count: int
|
||||
new_purchases_amount: float
|
||||
repeat_purchases_count: int
|
||||
repeat_purchases_amount: float
|
||||
|
||||
|
||||
class TrackingSourceResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
code: str
|
||||
type: str
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
|
||||
registrations: int = 0
|
||||
trials: int = 0
|
||||
payments: int = 0
|
||||
total_amount: float = 0.0
|
||||
|
||||
monthly: list[MonthlyStats] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,35 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ServerBase(BaseModel):
|
||||
cluster_name: str
|
||||
server_name: str
|
||||
api_url: str
|
||||
subscription_url: str | None = None
|
||||
inbound_id: str
|
||||
panel_type: str
|
||||
max_keys: int | None = None
|
||||
tariff_group: str | None = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ServerResponse(ServerBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ServerUpdate(BaseModel):
|
||||
cluster_name: str | None = None
|
||||
server_name: str | None = None
|
||||
api_url: str | None = None
|
||||
subscription_url: str | None = None
|
||||
inbound_id: str | None = None
|
||||
panel_type: str | None = None
|
||||
max_keys: int | None = None
|
||||
tariff_group: str | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TariffBase(BaseModel):
|
||||
name: str
|
||||
group_code: str
|
||||
duration_days: int
|
||||
price_rub: int
|
||||
traffic_limit: int | None = None
|
||||
device_limit: int | None = None
|
||||
is_active: bool = True
|
||||
subgroup_title: str | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class TariffResponse(TariffBase):
|
||||
id: int
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TariffUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
group_code: str | None = None
|
||||
duration_days: int | None = None
|
||||
price_rub: int | None = None
|
||||
traffic_limit: int | None = None
|
||||
device_limit: int | None = None
|
||||
is_active: bool | None = None
|
||||
subgroup_title: str | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
tg_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
language_code: str | None = None
|
||||
is_bot: bool | None = False
|
||||
balance: float = 0.0
|
||||
trial: int = 0
|
||||
source_code: str | None = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
language_code: str | None = None
|
||||
is_bot: bool | None = None
|
||||
balance: float | None = None
|
||||
trial: int | None = None
|
||||
source_code: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,102 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
username TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
language_code TEXT,
|
||||
is_bot BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
tg_id BIGINT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
payment_system TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'success',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tg_id) REFERENCES users (tg_id)
|
||||
);
|
||||
|
||||
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,
|
||||
server_id TEXT NOT NULL DEFAULT 'cluster1',
|
||||
notified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (tg_id, client_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referrals
|
||||
(
|
||||
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
referrer_tg_id BIGINT NOT NULL,
|
||||
reward_issued BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupons
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupon_usages
|
||||
(
|
||||
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL,
|
||||
used_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (coupon_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications
|
||||
(
|
||||
tg_id BIGINT NOT NULL,
|
||||
last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
notification_type TEXT NOT NULL,
|
||||
PRIMARY KEY (tg_id, notification_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
cluster_name TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
subscription_url TEXT NOT NULL,
|
||||
inbound_id TEXT NOT NULL,
|
||||
UNIQUE (cluster_name, server_name)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gifts
|
||||
(
|
||||
gift_id TEXT PRIMARY KEY NOT NULL,
|
||||
sender_tg_id BIGINT NOT NULL,
|
||||
selected_months INTEGER NOT NULL,
|
||||
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
gift_link TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recipient_tg_id BIGINT,
|
||||
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
|
||||
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
|
||||
);
|
||||
@@ -1,100 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def backup_database():
|
||||
from bot import bot
|
||||
|
||||
try:
|
||||
if backup_file_path := _create_database_backup():
|
||||
await _send_backup_to_admin(bot, backup_file_path)
|
||||
_cleanup_old_backups()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании или отправке бэкапа: {e}")
|
||||
|
||||
|
||||
def _create_database_backup():
|
||||
USER = DB_USER
|
||||
HOST = "localhost"
|
||||
BACKUP_DIR = BACK_DIR
|
||||
DATE = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
BACKUP_FILE = f"{BACKUP_DIR}/{DB_NAME}-backup-{DATE}.sql"
|
||||
|
||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"pg_dump",
|
||||
"-U",
|
||||
USER,
|
||||
"-h",
|
||||
HOST,
|
||||
"-F",
|
||||
"c",
|
||||
"-f",
|
||||
BACKUP_FILE,
|
||||
DB_NAME,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
logger.info(f"Бэкап базы данных создан: {BACKUP_FILE}")
|
||||
return BACKUP_FILE
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Ошибка при создании бэкапа базы данных: {e}")
|
||||
return None
|
||||
finally:
|
||||
del os.environ["PGPASSWORD"]
|
||||
|
||||
|
||||
async def _send_backup_to_admin(bot, backup_file_path):
|
||||
try:
|
||||
with open(backup_file_path, "rb") as backup_file:
|
||||
backup_input_file = BufferedInputFile(
|
||||
backup_file.read(), filename=os.path.basename(backup_file_path)
|
||||
)
|
||||
admin_ids: int | list[int] = ADMIN_ID
|
||||
if isinstance(admin_ids, list):
|
||||
for id in admin_ids:
|
||||
await bot.send_document(id, backup_input_file)
|
||||
logger.info(f"Бэкап базы данных отправлен админу: {id}")
|
||||
else:
|
||||
await bot.send_document(admin_ids, backup_input_file)
|
||||
logger.info(f"Бэкап базы данных отправлен админу: {ADMIN_ID}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
|
||||
|
||||
|
||||
def _cleanup_old_backups():
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"find",
|
||||
BACK_DIR,
|
||||
"-type",
|
||||
"f",
|
||||
"-name",
|
||||
"*.sql",
|
||||
"-mtime",
|
||||
"+3",
|
||||
"-exec",
|
||||
"rm",
|
||||
"{}",
|
||||
";",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
logger.info("Старые бэкапы удалены.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
|
||||
|
||||
|
||||
async def create_backup_and_send_to_admins(xui):
|
||||
await xui.login()
|
||||
await xui.database.export()
|
||||
@@ -3,42 +3,149 @@ import traceback
|
||||
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.types import ErrorEvent
|
||||
from aiogram.types import BufferedInputFile, ErrorEvent
|
||||
from aiogram.utils.markdown import hbold
|
||||
|
||||
from config import API_TOKEN
|
||||
from config import ADMIN_ID, API_TOKEN
|
||||
from database import async_session_maker
|
||||
from filters.private import IsPrivateFilter
|
||||
from logger import logger
|
||||
from middlewares.admin import AdminMiddleware
|
||||
from middlewares.database import DatabaseMiddleware
|
||||
from middlewares.delete import DeleteMessageMiddleware
|
||||
from middlewares.logging import LoggingMiddleware
|
||||
from middlewares.user import UserMiddleware
|
||||
from utils.modules_loader import load_modules_from_folder, modules_hub
|
||||
|
||||
|
||||
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.include_router(modules_hub)
|
||||
|
||||
dp.message.middleware(AdminMiddleware())
|
||||
dp.callback_query.middleware(AdminMiddleware())
|
||||
dp.message.middleware(UserMiddleware())
|
||||
dp.callback_query.middleware(UserMiddleware())
|
||||
load_modules_from_folder()
|
||||
|
||||
dp.message.middleware(DatabaseMiddleware())
|
||||
dp.callback_query.middleware(DatabaseMiddleware())
|
||||
|
||||
dp.message.outer_middleware(DeleteMessageMiddleware())
|
||||
dp.callback_query.outer_middleware(DeleteMessageMiddleware())
|
||||
dp.message.filter(IsPrivateFilter())
|
||||
dp.callback_query.filter(IsPrivateFilter())
|
||||
|
||||
|
||||
@dp.error()
|
||||
async def error_handler(event: ErrorEvent):
|
||||
logger.error(
|
||||
"Ошибка в боте:\n"
|
||||
f"Исключение: {event.exception}\n"
|
||||
f"Тип: {type(event.exception)}\n"
|
||||
f"Update: {event.update}\n"
|
||||
f"Трассировка:\n{traceback.format_exc()}"
|
||||
)
|
||||
@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
|
||||
):
|
||||
try:
|
||||
tb = "".join(
|
||||
traceback.format_exception(type(event.exception), event.exception, event.exception.__traceback__)
|
||||
)
|
||||
logger.warning(f"Показываем стартовое меню из-за TelegramBadRequest: {error_message}")
|
||||
logger.error(f"Traceback:\n{tb}")
|
||||
|
||||
if ADMIN_ID:
|
||||
for admin_id in ADMIN_ID:
|
||||
await bot.send_document(
|
||||
chat_id=admin_id,
|
||||
document=BufferedInputFile(
|
||||
tb.encode(),
|
||||
filename=f"error_{event.update.update_id}.txt",
|
||||
),
|
||||
caption=f"{hbold(type(event.exception).__name__)}: {error_message[:1021]}...",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Сбой при логировании/отправке ошибки админу: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
from handlers.start import start_entry
|
||||
|
||||
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,
|
||||
)
|
||||
async with async_session_maker() as session:
|
||||
await start_entry(
|
||||
event=event.update.message,
|
||||
state=fsm_context,
|
||||
session=session,
|
||||
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,
|
||||
)
|
||||
async with async_session_maker() as session:
|
||||
await start_entry(
|
||||
event=event.update.callback_query,
|
||||
state=fsm_context,
|
||||
session=session,
|
||||
admin=False,
|
||||
captcha=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}", exc_info=True)
|
||||
|
||||
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]}...",
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
async with async_session_maker() as session:
|
||||
await start_entry(
|
||||
event=event.update.message,
|
||||
state=fsm_context,
|
||||
session=session,
|
||||
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,
|
||||
)
|
||||
async with async_session_maker() as session:
|
||||
await start_entry(
|
||||
event=event.update.callback_query,
|
||||
state=fsm_context,
|
||||
session=session,
|
||||
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
+633
@@ -0,0 +1,633 @@
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from time import sleep
|
||||
|
||||
import requests
|
||||
|
||||
from rich.console import Console, Group
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
from rich.prompt import Confirm, Prompt
|
||||
from rich.table import Table
|
||||
|
||||
from config import BOT_SERVICE
|
||||
|
||||
|
||||
def ensure_utf8_locale():
|
||||
try:
|
||||
current_locale = locale.getlocale()
|
||||
if current_locale and current_locale[1] == "UTF-8":
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
console.print("[yellow]⏳ Проверка и установка локали UTF-8...[/yellow]")
|
||||
|
||||
os.environ["LC_ALL"] = "en_US.UTF-8"
|
||||
os.environ["LANG"] = "en_US.UTF-8"
|
||||
|
||||
result = subprocess.run(["locale", "-a"], capture_output=True, text=True)
|
||||
if "en_US.utf8" not in result.stdout.lower():
|
||||
console.print("[blue]Добавляю локаль en_US.UTF-8 в систему...[/blue]")
|
||||
try:
|
||||
subprocess.run(["sudo", "locale-gen", "en_US.UTF-8"], check=True)
|
||||
subprocess.run(["sudo", "update-locale", "LANG=en_US.UTF-8"], check=True)
|
||||
console.print("[green]Локаль успешно установлена.[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при установке локали: {e}[/red]")
|
||||
else:
|
||||
console.print("[green]Локаль UTF-8 уже доступна в системе.[/green]")
|
||||
|
||||
|
||||
try:
|
||||
sys.stdin.reconfigure(encoding="utf-8")
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
console = Console()
|
||||
ensure_utf8_locale()
|
||||
|
||||
BACK_DIR = os.path.expanduser("~/.solobot_backups")
|
||||
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("[red]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/red]")
|
||||
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_lines = [
|
||||
"███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗",
|
||||
"██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝",
|
||||
"███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║ ",
|
||||
"╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║ ",
|
||||
"███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║ ",
|
||||
"╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ",
|
||||
]
|
||||
|
||||
with Live(refresh_per_second=10) as live:
|
||||
display = []
|
||||
for line in logo_lines:
|
||||
display.append(f"[bold cyan]{line}[/bold cyan]")
|
||||
panel = Panel(Group(*display), border_style="cyan", padding=(0, 2), expand=False)
|
||||
live.update(panel)
|
||||
sleep(0.07)
|
||||
|
||||
console.print(f"[bold green]Директория бота:[/bold green] [yellow]{PROJECT_DIR}[/yellow]\n")
|
||||
|
||||
|
||||
def list_backups():
|
||||
if not os.path.isdir(BACK_DIR):
|
||||
return []
|
||||
pairs = []
|
||||
for name in os.listdir(BACK_DIR):
|
||||
path = os.path.join(BACK_DIR, name)
|
||||
if os.path.isdir(path):
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except Exception:
|
||||
mtime = 0
|
||||
pairs.append((mtime, path))
|
||||
pairs.sort(reverse=True)
|
||||
return [p for _, p in pairs]
|
||||
|
||||
|
||||
def prune_old_backups():
|
||||
backups = list_backups()
|
||||
for path in backups[3:]:
|
||||
try:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
except Exception:
|
||||
subprocess.run(["sudo", "rm", "-rf", path])
|
||||
|
||||
|
||||
def backup_project():
|
||||
from datetime import datetime
|
||||
|
||||
os.makedirs(BACK_DIR, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
dst = os.path.join(BACK_DIR, f"backup-{ts}")
|
||||
console.print("[yellow]Создаётся резервная копия проекта...[/yellow]")
|
||||
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
|
||||
subprocess.run(["cp", "-r", PROJECT_DIR, dst])
|
||||
console.print(f"[green]Бэкап сохранён в: {dst}[/green]")
|
||||
prune_old_backups()
|
||||
|
||||
|
||||
def restore_from_backup():
|
||||
from datetime import datetime
|
||||
|
||||
backups = list_backups()[:3]
|
||||
if not backups:
|
||||
console.print(f"[red]❌ Бэкапы не найдены: {BACK_DIR}[/red]")
|
||||
return
|
||||
|
||||
console.print("\n[bold green]Доступные бэкапы:[/bold green]")
|
||||
shown = []
|
||||
for idx, path in enumerate(backups, 1):
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
dt = "unknown"
|
||||
console.print(f"[cyan]{idx}.[/cyan] {os.path.basename(path)} [dim]{dt}[/dim]")
|
||||
shown.append((idx, path))
|
||||
|
||||
try:
|
||||
choice = Prompt.ask(
|
||||
"[bold blue]Выберите номер бэкапа[/bold blue]",
|
||||
choices=[str(i) for i, _ in shown],
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
sel_path = shown[int(choice) - 1][1]
|
||||
|
||||
console.print("[red]Внимание: текущие файлы проекта будут перезаписаны выбранным бэкапом.[/red]")
|
||||
if not Confirm.ask("[yellow]Продолжить восстановление из бэкапа?[/yellow]"):
|
||||
return
|
||||
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
console.print("[blue]Останавливаю службу перед восстановлением...[/blue]")
|
||||
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
|
||||
|
||||
install_rsync_if_needed()
|
||||
|
||||
console.print("[yellow]Копирую файлы из бэкапа в проект...[/yellow]")
|
||||
rc = subprocess.run(f"rsync -a --delete {sel_path}/ {PROJECT_DIR}/", shell=True).returncode
|
||||
if rc != 0:
|
||||
console.print("[red]❌ Ошибка rsync при восстановлении[/red]")
|
||||
return
|
||||
|
||||
install_dependencies()
|
||||
fix_permissions()
|
||||
restart_service()
|
||||
console.print("[green]✅ Восстановление из бэкапа завершено[/green]")
|
||||
|
||||
|
||||
def auto_update_cli():
|
||||
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:
|
||||
console.print("[red]Не удалось получить обновление CLI[/red]")
|
||||
return
|
||||
|
||||
latest_text = response.text
|
||||
current_path = os.path.realpath(__file__)
|
||||
with open(current_path, encoding="utf-8") as f:
|
||||
current_text = f.read()
|
||||
|
||||
if current_text != latest_text:
|
||||
console.print("[green]Доступна новая версия CLI. Обновляю...[/green]")
|
||||
with open(current_path, "w", encoding="utf-8") as f:
|
||||
f.write(latest_text)
|
||||
os.chmod(current_path, 0o755)
|
||||
console.print("[green]CLI обновлён. Перезапуск...[/green]")
|
||||
os.execv(sys.executable, [sys.executable, current_path])
|
||||
else:
|
||||
console.print("[green]CLI уже актуален[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при автообновлении CLI: {e}[/red]")
|
||||
|
||||
|
||||
def fix_permissions():
|
||||
console.print("[yellow]Восстанавливаю владельца и права доступа к проекту...[/yellow]")
|
||||
|
||||
try:
|
||||
user = os.environ.get("SUDO_USER") or subprocess.check_output(["whoami"], text=True).strip()
|
||||
console.log(f"[cyan]Используем пользователь: {user}[/cyan]")
|
||||
|
||||
for root, dirs, files in os.walk(PROJECT_DIR):
|
||||
for dir in dirs:
|
||||
if dir == "__pycache__":
|
||||
pycache_path = os.path.join(root, dir)
|
||||
subprocess.run(["sudo", "rm", "-rf", pycache_path], check=True)
|
||||
for file in files:
|
||||
if file.endswith(".pyc"):
|
||||
pyc_path = os.path.join(root, file)
|
||||
subprocess.run(["sudo", "rm", "-f", pyc_path], check=True)
|
||||
|
||||
console.log("[blue]Изменение владельца на весь проект...[/blue]")
|
||||
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
|
||||
|
||||
console.log("[blue]Изменение прав доступа (u=rwX,go=rX)...[/blue]")
|
||||
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
|
||||
|
||||
launcher_path = os.path.join(PROJECT_DIR, "cli_launcher.py")
|
||||
if os.path.exists(launcher_path):
|
||||
console.log("[blue]Установка флага +x для cli_launcher.py...[/blue]")
|
||||
subprocess.run(["chmod", "+x", launcher_path], check=True)
|
||||
|
||||
console.print(f"[green]Все права восстановлены для пользователя [bold]{user}[/bold][/green]")
|
||||
|
||||
except Exception 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 = set()
|
||||
|
||||
preserved_paths.update([
|
||||
os.path.join(PROJECT_DIR, "config.py"),
|
||||
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
|
||||
os.path.join(PROJECT_DIR, ".git"),
|
||||
os.path.join(PROJECT_DIR, "modules"),
|
||||
])
|
||||
|
||||
for root, dirs, files in os.walk(os.path.join(PROJECT_DIR, "modules")):
|
||||
for name in dirs + files:
|
||||
preserved_paths.add(os.path.join(root, name))
|
||||
|
||||
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, dirs, files in os.walk(os.path.join(PROJECT_DIR, "img")):
|
||||
for name in dirs + files:
|
||||
preserved_paths.add(os.path.join(root, name))
|
||||
|
||||
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) in [
|
||||
os.path.join(PROJECT_DIR, "handlers"),
|
||||
os.path.join(PROJECT_DIR, "img"),
|
||||
os.path.join(PROJECT_DIR, "modules"),
|
||||
]:
|
||||
continue
|
||||
|
||||
if os.path.abspath(dir_path).startswith(os.path.join(PROJECT_DIR, "modules") + os.sep):
|
||||
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]")
|
||||
|
||||
python312_path = shutil.which("python3.12")
|
||||
if not python312_path:
|
||||
console.print("[red]Не найден python3.12 в системе[/red]")
|
||||
console.print("[yellow]Установите Python 3.12: sudo apt install python3.12 python3.12-venv[/yellow]")
|
||||
sys.exit(1)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(style="green"),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
transient=True,
|
||||
) as progress:
|
||||
task_id = progress.add_task(description="Создание виртуального окружения...", total=None)
|
||||
try:
|
||||
if os.path.exists("venv"):
|
||||
shutil.rmtree("venv")
|
||||
console.print("[yellow]Удалён старый venv[/yellow]")
|
||||
|
||||
subprocess.run(f"{python312_path} -m venv venv", shell=True, check=True)
|
||||
|
||||
progress.update(task_id, description="Установка зависимостей...")
|
||||
subprocess.run(
|
||||
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
progress.update(task_id, description="Установка завершена")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
progress.update(task_id, description="❌ Ошибка при установке")
|
||||
console.print(f"[red]❌ Ошибка: {e}[/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():
|
||||
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
|
||||
|
||||
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 "
|
||||
exclude_options += "--exclude=modules "
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
|
||||
modules_path = os.path.join(PROJECT_DIR, "modules")
|
||||
if not os.path.exists(modules_path):
|
||||
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
|
||||
try:
|
||||
os.makedirs(modules_path, exist_ok=True)
|
||||
console.print("[green]Папка modules успешно создана.[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
|
||||
|
||||
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])
|
||||
|
||||
install_dependencies()
|
||||
fix_permissions()
|
||||
restart_service()
|
||||
console.print("[green]Обновление с ветки dev завершено.[/green]")
|
||||
|
||||
|
||||
def update_from_release():
|
||||
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 "
|
||||
exclude_options += "--exclude=modules "
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
|
||||
modules_path = os.path.join(PROJECT_DIR, "modules")
|
||||
if not os.path.exists(modules_path):
|
||||
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
|
||||
try:
|
||||
os.makedirs(modules_path, exist_ok=True)
|
||||
console.print("[green]Папка modules успешно создана.[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
|
||||
|
||||
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])
|
||||
|
||||
install_dependencies()
|
||||
fix_permissions()
|
||||
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.3.3", 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", "Восстановить из бэкапа")
|
||||
table.add_row("9", "Выход")
|
||||
console.print(table)
|
||||
|
||||
|
||||
def main():
|
||||
os.chdir(PROJECT_DIR)
|
||||
auto_update_cli()
|
||||
print_logo()
|
||||
try:
|
||||
while True:
|
||||
show_menu()
|
||||
choice = Prompt.ask(
|
||||
"[bold blue]👉 Введите номер действия[/bold blue]",
|
||||
choices=[str(i) for i in range(1, 10)],
|
||||
show_choices=False,
|
||||
)
|
||||
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":
|
||||
restore_from_backup()
|
||||
elif choice == "9":
|
||||
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[bold red]⏹ Прерывание. Выход из CLI.[/bold red]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,115 +0,0 @@
|
||||
import py3xui
|
||||
|
||||
from config import LIMIT_IP
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def add_client(
|
||||
xui,
|
||||
client_id: str,
|
||||
email: str,
|
||||
tg_id: str,
|
||||
limit_ip: int,
|
||||
total_gb: int,
|
||||
expiry_time: int,
|
||||
enable: bool,
|
||||
flow: str,
|
||||
inbound_id: int,
|
||||
):
|
||||
"""
|
||||
Adds a client to the server via 3x-ui.
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
|
||||
client = py3xui.Client(
|
||||
id=client_id,
|
||||
email=email.lower(),
|
||||
limit_ip=limit_ip,
|
||||
total_gb=total_gb,
|
||||
expiry_time=expiry_time,
|
||||
enable=enable,
|
||||
tg_id=tg_id,
|
||||
sub_id=email,
|
||||
flow=flow,
|
||||
)
|
||||
|
||||
response = await xui.client.add(inbound_id, [client])
|
||||
|
||||
logger.info(f"Клиент {email} успешно добавлен с ID {client_id}.")
|
||||
|
||||
return response if response else {"status": "failed"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении клиента {email}: {e}")
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
|
||||
async def extend_client_key(
|
||||
xui, inbound_id, email: str, new_expiry_time: int, client_id: str, total_gb: int
|
||||
):
|
||||
"""
|
||||
Функция для обновления срока действия ключа клиента по email.
|
||||
"""
|
||||
await xui.login()
|
||||
try:
|
||||
client = await xui.client.get_by_email(email)
|
||||
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} не найден.")
|
||||
return
|
||||
|
||||
if not client.id:
|
||||
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID.")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Обновление ключа клиента {client.email} с ID {client.id} до нового времени: {new_expiry_time}"
|
||||
)
|
||||
|
||||
client.id = client_id
|
||||
client.expiry_time = new_expiry_time
|
||||
client.flow = "xtls-rprx-vision"
|
||||
client.sub_id = email
|
||||
client.total_gb = total_gb
|
||||
client.enable = True
|
||||
client.limit_ip = LIMIT_IP
|
||||
client.inbound_id = inbound_id
|
||||
|
||||
await xui.client.update(client.id, client)
|
||||
await xui.client.reset_stats(inbound_id, email)
|
||||
logger.info(
|
||||
f"Ключ клиента {client.email} успешно продлён до {new_expiry_time}."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обновлении клиента с email {email}: {e}")
|
||||
|
||||
|
||||
async def delete_client(
|
||||
xui,
|
||||
inbound_id: int,
|
||||
email: str,
|
||||
client_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Функция для удаления клиента с сервера 3x-ui.
|
||||
Возвращает True при успешном удалении, иначе False.
|
||||
"""
|
||||
await xui.login()
|
||||
try:
|
||||
client = await xui.client.get_by_email(email)
|
||||
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
|
||||
return False
|
||||
|
||||
client.id = client_id
|
||||
|
||||
await xui.client.delete(inbound_id, client.id)
|
||||
logger.info(f"Клиент с ID {client_id} был удален успешно.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении клиента с ID {client_id}: {e}")
|
||||
return False
|
||||
Binary file not shown.
Binary file not shown.
-1269
File diff suppressed because it is too large
Load Diff
@@ -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,10 @@
|
||||
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,109 @@
|
||||
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,11 @@
|
||||
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, pool_size=20, max_overflow=30, pool_timeout=15)
|
||||
|
||||
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -0,0 +1,43 @@
|
||||
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,
|
||||
is_unlimited: bool = False,
|
||||
max_usages: 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,
|
||||
is_unlimited=is_unlimited,
|
||||
max_usages=max_usages,
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"🎁 Подарок {gift_id} сохранён (tariff_id={tariff_id}, max_usages={max_usages})")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Payment, User
|
||||
|
||||
|
||||
async def get_hot_leads(session: AsyncSession):
|
||||
now_ms = func.extract("epoch", func.now()) * 1000
|
||||
|
||||
sub_active = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
|
||||
|
||||
stmt = (
|
||||
select(Payment.tg_id)
|
||||
.join(User, User.tg_id == Payment.tg_id)
|
||||
.distinct()
|
||||
.where(User.trial == 1)
|
||||
.where(Payment.amount > 0)
|
||||
.where(Payment.status == "success")
|
||||
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
|
||||
.where(~Payment.tg_id.in_(sub_active))
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,123 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
from datetime import datetime
|
||||
from itertools import cycle
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import USE_COUNTRY_SELECTION
|
||||
from database.models import Key, Server, User
|
||||
|
||||
|
||||
async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple[int, int]:
|
||||
imported = 0
|
||||
skipped = 0
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
result = await session.execute(
|
||||
select(Server.server_name).where(Server.enabled.is_(True), Server.panel_type == "3x-ui")
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Server.cluster_name)
|
||||
.where(Server.enabled.is_(True), Server.panel_type == "3x-ui", Server.cluster_name.isnot(None))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
server_ids = [row[0] for row in result.fetchall()]
|
||||
if not server_ids:
|
||||
raise RuntimeError("❌ Не найдено доступных серверов или кластеров для 3x-ui")
|
||||
|
||||
server_cycle = cycle(server_ids)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id, remark, settings FROM inbounds")
|
||||
inbounds = cursor.fetchall()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Не удалось прочитать SQLite: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
parsed_clients = []
|
||||
for inbound_id, _remark, settings_raw in inbounds:
|
||||
try:
|
||||
settings = json.loads(settings_raw)
|
||||
clients = settings.get("clients", [])
|
||||
for c in clients:
|
||||
expiry = c.get("expiryTime")
|
||||
c["expiryTime"] = int(float(expiry)) if expiry else 0
|
||||
c["limitIp"] = int(c.get("limitIp", 0) or 0)
|
||||
c["inbound_id"] = inbound_id
|
||||
parsed_clients.append(c)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
now_ts = int(time.time() * 1000)
|
||||
|
||||
for c in parsed_clients:
|
||||
tg_id = c.get("tgId")
|
||||
client_id = str(c.get("id"))
|
||||
email = c.get("email")
|
||||
expiry_time = int(c.get("expiryTime") or now_ts)
|
||||
created_at = now_ts
|
||||
server_id = next(server_cycle)
|
||||
|
||||
if not tg_id or not client_id:
|
||||
continue
|
||||
|
||||
user_exists = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||
if not user_exists.scalar():
|
||||
try:
|
||||
session.add(
|
||||
User(
|
||||
tg_id=tg_id,
|
||||
username=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code=None,
|
||||
is_bot=False,
|
||||
balance=0.0,
|
||||
trial=1,
|
||||
source_code=None,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
continue
|
||||
|
||||
key_exists = await session.execute(select(Key).where(Key.client_id == client_id))
|
||||
if key_exists.scalar():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
session.add(
|
||||
Key(
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
created_at=created_at,
|
||||
expiry_time=expiry_time,
|
||||
key="",
|
||||
server_id=server_id,
|
||||
remnawave_link=None,
|
||||
tariff_id=None,
|
||||
is_frozen=False,
|
||||
alias=None,
|
||||
notified=False,
|
||||
notified_24h=False,
|
||||
)
|
||||
)
|
||||
imported += 1
|
||||
except SQLAlchemyError:
|
||||
continue
|
||||
|
||||
await session.commit()
|
||||
return imported, skipped
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import ADMIN_ID
|
||||
from database.db import async_session_maker, engine
|
||||
from database.models import Admin, Base, User
|
||||
from database.tariffs import initialize_all_tariff_weights
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(User).where(User.tg_id == 0))
|
||||
if not result.scalar_one_or_none():
|
||||
session.add(
|
||||
User(
|
||||
tg_id=0,
|
||||
username="system",
|
||||
first_name="System",
|
||||
is_bot=True,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
for tg_id in ADMIN_ID:
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
if not result.scalar_one_or_none():
|
||||
session.add(
|
||||
Admin(
|
||||
tg_id=tg_id, role="superadmin", description="Imported from config", added_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await initialize_all_tariff_weights(session)
|
||||
@@ -0,0 +1,201 @@
|
||||
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,
|
||||
alias: str = None,
|
||||
):
|
||||
try:
|
||||
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
|
||||
existing_key = exists.scalar_one_or_none()
|
||||
|
||||
if existing_key:
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.tg_id == tg_id, Key.client_id == client_id)
|
||||
.values(
|
||||
email=email,
|
||||
expiry_time=expiry_time,
|
||||
key=key,
|
||||
server_id=server_id,
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=tariff_id,
|
||||
alias=alias,
|
||||
)
|
||||
)
|
||||
logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
|
||||
else:
|
||||
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,
|
||||
alias=alias,
|
||||
)
|
||||
session.add(new_key)
|
||||
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
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))
|
||||
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}")
|
||||
|
||||
|
||||
async def get_subscription_link(session: AsyncSession, email: str) -> str | None:
|
||||
result = await session.execute(select(func.coalesce(Key.key, Key.remnawave_link)).where(Key.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_key_client_id(session: AsyncSession, email: str, new_client_id: str):
|
||||
await session.execute(update(Key).where(Key.email == email).values(client_id=new_client_id))
|
||||
await session.commit()
|
||||
logger.info(f"client_id обновлён для {email} -> {new_client_id}")
|
||||
|
||||
|
||||
async def update_key_link(session: AsyncSession, email: str, link: str) -> bool:
|
||||
q = update(Key).where(Key.email == email).values(key=link).returning(Key.client_id)
|
||||
res = await session.execute(q)
|
||||
await session.commit()
|
||||
return res.scalar_one_or_none() is not None
|
||||
@@ -0,0 +1,264 @@
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
|
||||
|
||||
|
||||
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)
|
||||
preferred_currency = Column(String(10), nullable=False, server_default="RUB", index=True)
|
||||
source_code = Column(
|
||||
String,
|
||||
ForeignKey(
|
||||
"tracking_sources.code",
|
||||
ondelete="SET NULL",
|
||||
onupdate="CASCADE",
|
||||
),
|
||||
nullable=True,
|
||||
)
|
||||
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", ondelete="SET NULL"))
|
||||
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)
|
||||
subgroup_title = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, nullable=True)
|
||||
vless = Column(Boolean, default=False)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
subgroups = relationship("ServerSubgroup", back_populates="server", cascade="all, delete-orphan")
|
||||
groups = relationship("ServerSpecialgroup", back_populates="server", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ServerSubgroup(DictLikeMixin, Base):
|
||||
__tablename__ = "server_subgroups"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
|
||||
group_code = Column(String, nullable=False)
|
||||
subgroup_title = Column(String, nullable=False)
|
||||
|
||||
server = relationship("Server", back_populates="subgroups")
|
||||
|
||||
__table_args__ = (UniqueConstraint("server_id", "subgroup_title", name="uq_server_subgroup"),)
|
||||
|
||||
|
||||
class ServerSpecialgroup(DictLikeMixin, Base):
|
||||
__tablename__ = "server_specialgroups"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
|
||||
group_code = Column(String, nullable=False)
|
||||
|
||||
server = relationship("Server")
|
||||
|
||||
__table_args__ = (UniqueConstraint("server_id", "group_code", name="uq_server_group"),)
|
||||
|
||||
|
||||
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)
|
||||
original_amount = Column(Numeric(18, 8), nullable=True)
|
||||
currency = Column(String(10), nullable=False, server_default="RUB")
|
||||
payment_id = Column(String(128), nullable=True, index=True)
|
||||
metadata_ = Column("metadata", JSONB, nullable=True)
|
||||
|
||||
|
||||
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", ondelete="CASCADE"), 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", ondelete="CASCADE"), primary_key=True)
|
||||
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
|
||||
reward_issued = Column(Boolean, default=False)
|
||||
|
||||
|
||||
class Notification(DictLikeMixin, Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), 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, default=lambda: uuid.uuid4().hex)
|
||||
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)
|
||||
is_unlimited = Column(Boolean, default=False)
|
||||
max_usages = Column(Integer, nullable=True)
|
||||
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
|
||||
|
||||
|
||||
class GiftUsage(DictLikeMixin, Base):
|
||||
__tablename__ = "gift_usages"
|
||||
|
||||
gift_id = Column(String, ForeignKey("gifts.gift_id"), primary_key=True)
|
||||
tg_id = Column(BigInteger, primary_key=True)
|
||||
used_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
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 Admin(Base):
|
||||
__tablename__ = "admins"
|
||||
|
||||
tg_id = Column(BigInteger, primary_key=True)
|
||||
token = Column(String, unique=True, nullable=True)
|
||||
description = Column(String, nullable=True)
|
||||
role = Column(String, nullable=False, default="admin")
|
||||
added_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
@staticmethod
|
||||
def generate_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
@@ -0,0 +1,168 @@
|
||||
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 config import DISCOUNT_ACTIVE_HOURS
|
||||
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.debug(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_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Notification.notification_type, Notification.last_notification_time)
|
||||
.where(Notification.tg_id == tg_id)
|
||||
.where(Notification.notification_type.in_(["hot_lead_step_2", "hot_lead_step_3"]))
|
||||
.order_by(Notification.last_notification_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
row = result.first()
|
||||
if not row:
|
||||
return {"available": False}
|
||||
|
||||
notification_type, last_time = row
|
||||
|
||||
expires_at = last_time + timedelta(hours=DISCOUNT_ACTIVE_HOURS)
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if current_time > expires_at:
|
||||
return {"available": False}
|
||||
|
||||
tariff_group = "discounts" if notification_type == "hot_lead_step_2" else "discounts_max"
|
||||
|
||||
return {"available": True, "type": notification_type, "tariff_group": tariff_group, "expires_at": expires_at}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при проверке скидки горячего лида для {tg_id}: {e}")
|
||||
return {"available": False}
|
||||
|
||||
|
||||
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 select
|
||||
|
||||
from database.models import BlockedUser, Notification
|
||||
|
||||
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,165 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from pytz import timezone
|
||||
from sqlalchemy import and_, insert, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Payment
|
||||
from logger import logger
|
||||
|
||||
|
||||
MOSCOW_TZ = timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def add_payment(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
amount: float,
|
||||
payment_system: str,
|
||||
*,
|
||||
status: str = "success",
|
||||
currency: str = "RUB",
|
||||
payment_id: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
original_amount: float | None = None,
|
||||
) -> int:
|
||||
try:
|
||||
now_moscow = datetime.now(MOSCOW_TZ).replace(tzinfo=None)
|
||||
stmt = (
|
||||
insert(Payment)
|
||||
.values(
|
||||
tg_id=tg_id,
|
||||
amount=amount,
|
||||
payment_system=payment_system,
|
||||
status=status,
|
||||
created_at=now_moscow,
|
||||
currency=currency,
|
||||
payment_id=payment_id,
|
||||
metadata_=metadata,
|
||||
original_amount=original_amount,
|
||||
)
|
||||
.returning(Payment.id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
internal_id = result.scalar_one()
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Добавлен платёж id={internal_id}: tg_id={tg_id}, amount={amount}, system={payment_system}, status={status}"
|
||||
)
|
||||
return internal_id
|
||||
except SQLAlchemyError as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Ошибка при добавлении платежа: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_last_payments(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
limit: int = 3,
|
||||
statuses: list[str] | None = None,
|
||||
):
|
||||
query = select(Payment).where(Payment.tg_id == tg_id)
|
||||
|
||||
if statuses:
|
||||
query = query.where(Payment.status.in_(statuses))
|
||||
|
||||
query = query.order_by(Payment.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
payments = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"tg_id": p.tg_id,
|
||||
"amount": p.amount,
|
||||
"currency": p.currency,
|
||||
"status": p.status,
|
||||
"payment_system": p.payment_system,
|
||||
"payment_id": p.payment_id,
|
||||
"created_at": p.created_at,
|
||||
"metadata": p.metadata_,
|
||||
"original_amount": p.original_amount,
|
||||
}
|
||||
for p in payments
|
||||
]
|
||||
|
||||
|
||||
async def get_payment_by_id(session: AsyncSession, internal_id: int) -> dict | None:
|
||||
try:
|
||||
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
|
||||
payment = result.scalar_one_or_none()
|
||||
if payment:
|
||||
logger.info(f"Найден платёж id={internal_id}")
|
||||
return dict(payment.__dict__)
|
||||
logger.info(f"Платёж id={internal_id} не найден")
|
||||
return None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при поиске платежа id={internal_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def update_payment_status(
|
||||
session: AsyncSession,
|
||||
internal_id: int,
|
||||
new_status: str,
|
||||
*,
|
||||
payment_id: str | None = None,
|
||||
metadata_patch: dict | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
|
||||
payment = result.scalar_one_or_none()
|
||||
if not payment:
|
||||
logger.info(f"Не удалось сменить статус: платёж id={internal_id} не найден")
|
||||
return False
|
||||
|
||||
payment.status = new_status
|
||||
if payment_id is not None:
|
||||
payment.payment_id = payment_id
|
||||
if metadata_patch:
|
||||
base = payment.metadata_ or {}
|
||||
base.update(metadata_patch)
|
||||
payment.metadata_ = base
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"Статус платежа id={internal_id} изменён на {new_status}")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Ошибка при смене статуса платежа id={internal_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_payment_by_payment_id(session: AsyncSession, pid: str) -> dict | None:
|
||||
try:
|
||||
result = await session.execute(select(Payment).where(Payment.payment_id == pid).limit(1))
|
||||
payment = result.scalar_one_or_none()
|
||||
if payment:
|
||||
logger.info(f"Найден платёж payment_id={pid}")
|
||||
return dict(payment.__dict__)
|
||||
logger.info(f"Платёж payment_id={pid} не найден")
|
||||
return None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при поиске платежа payment_id={pid}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def cancel_expired_pending_payments(session: AsyncSession) -> int:
|
||||
cutoff = datetime.now(MOSCOW_TZ).replace(tzinfo=None) - timedelta(minutes=60)
|
||||
stmt = (
|
||||
update(Payment)
|
||||
.where(
|
||||
and_(
|
||||
Payment.status.in_(("pending", "issued", "processing", "awaiting_choice")),
|
||||
Payment.created_at < cutoff,
|
||||
)
|
||||
)
|
||||
.values(status="cancelled")
|
||||
.returning(Payment.id)
|
||||
)
|
||||
res = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return len(res.fetchall())
|
||||
@@ -0,0 +1,241 @@
|
||||
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,315 @@
|
||||
from sqlalchemy import delete, func, insert, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Key, Server, ServerSpecialgroup, ServerSubgroup, Tariff
|
||||
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:
|
||||
from handlers.utils import ALLOWED_GROUP_CODES
|
||||
|
||||
try:
|
||||
stmt = select(Server)
|
||||
result = await session.execute(stmt)
|
||||
servers = result.scalars().all()
|
||||
|
||||
ids = [s.id for s in servers]
|
||||
subs_map = {}
|
||||
if ids:
|
||||
r = await session.execute(
|
||||
select(ServerSubgroup.server_id, ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(ids))
|
||||
)
|
||||
for sid, sg in r.all():
|
||||
subs_map.setdefault(sid, []).append(sg)
|
||||
|
||||
groups_map = {}
|
||||
if ids:
|
||||
r2 = await session.execute(
|
||||
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
|
||||
ServerSpecialgroup.server_id.in_(ids)
|
||||
)
|
||||
)
|
||||
for sid, gc in r2.all():
|
||||
groups_map.setdefault(sid, []).append(gc)
|
||||
|
||||
allowed = set(ALLOWED_GROUP_CODES)
|
||||
|
||||
grouped = {}
|
||||
for s in servers:
|
||||
if not include_enabled and not s.enabled:
|
||||
continue
|
||||
cluster = s.cluster_name
|
||||
special = sorted({g for g in groups_map.get(s.id, []) if g in allowed})
|
||||
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,
|
||||
"tariff_subgroups": subs_map.get(s.id, []),
|
||||
"special_groups": special,
|
||||
"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
|
||||
|
||||
|
||||
async def get_server_by_name(session: AsyncSession, server_name: str) -> dict | None:
|
||||
try:
|
||||
stmt = select(Server).where(Server.server_name == server_name)
|
||||
result = await session.execute(stmt)
|
||||
server = result.scalar_one_or_none()
|
||||
|
||||
if server:
|
||||
return {
|
||||
"id": server.id,
|
||||
"cluster_name": server.cluster_name,
|
||||
"server_name": server.server_name,
|
||||
"api_url": server.api_url,
|
||||
"subscription_url": server.subscription_url,
|
||||
"inbound_id": server.inbound_id,
|
||||
"panel_type": server.panel_type,
|
||||
"enabled": server.enabled,
|
||||
"max_keys": server.max_keys,
|
||||
"tariff_group": server.tariff_group,
|
||||
}
|
||||
return None
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при получении сервера {server_name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def update_server_field(session: AsyncSession, server_name: str, field: str, value: any) -> bool:
|
||||
try:
|
||||
stmt = update(Server).where(Server.server_name == server_name).values(**{field: value})
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"✅ Поле {field} сервера {server_name} обновлено на {value}")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при обновлении поля {field} сервера {server_name}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def update_server_name_with_keys(session: AsyncSession, old_name: str, new_name: str) -> bool:
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
|
||||
from database.models import Key
|
||||
|
||||
if not await check_unique_server_name(session, new_name):
|
||||
logger.error(f"❌ Сервер с именем {new_name} уже существует")
|
||||
return False
|
||||
|
||||
stmt_server = update(Server).where(Server.server_name == old_name).values(server_name=new_name)
|
||||
await session.execute(stmt_server)
|
||||
|
||||
stmt_keys = update(Key).where(Key.server_id == old_name).values(server_id=new_name)
|
||||
await session.execute(stmt_keys)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"✅ Сервер переименован с {old_name} на {new_name}")
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при переименовании сервера {old_name}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_available_clusters(session: AsyncSession) -> list[str]:
|
||||
try:
|
||||
stmt = select(Server.cluster_name).distinct().order_by(Server.cluster_name)
|
||||
result = await session.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при получении списка кластеров: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def update_server_cluster(session: AsyncSession, server_name: str, new_cluster: str) -> bool:
|
||||
try:
|
||||
server_data = await get_server_by_name(session, server_name)
|
||||
if not server_data:
|
||||
return False
|
||||
|
||||
old_cluster = server_data["cluster_name"]
|
||||
|
||||
stmt_remaining = select(func.count()).where(
|
||||
(Server.cluster_name == old_cluster) & (Server.server_name != server_name)
|
||||
)
|
||||
result = await session.execute(stmt_remaining)
|
||||
remaining_servers = result.scalar_one()
|
||||
|
||||
if remaining_servers == 0:
|
||||
stmt_update_keys = update(Key).where(Key.server_id == old_cluster).values(server_id=new_cluster)
|
||||
await session.execute(stmt_update_keys)
|
||||
|
||||
stmt_new_cluster = select(Server.tariff_group).where(Server.cluster_name == new_cluster).limit(1)
|
||||
result = await session.execute(stmt_new_cluster)
|
||||
new_tariff_group = result.scalar_one_or_none()
|
||||
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(Server.server_name == server_name)
|
||||
.values(cluster_name=new_cluster, tariff_group=new_tariff_group)
|
||||
)
|
||||
|
||||
if server_data.get("id") is None:
|
||||
rid = await session.execute(select(Server.id).where(Server.server_name == server_name).limit(1))
|
||||
server_id = rid.scalar_one_or_none()
|
||||
else:
|
||||
server_id = server_data["id"]
|
||||
|
||||
if server_id is not None and new_tariff_group is not None:
|
||||
await session.execute(
|
||||
update(ServerSubgroup).where(ServerSubgroup.server_id == server_id).values(group_code=new_tariff_group)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"✅ Сервер {server_name} перемещен в кластер {new_cluster} с обновлением тарифной группы и привязок подгрупп"
|
||||
)
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при обновлении кластера сервера {server_name}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def resolve_device_limit_from_group(session: AsyncSession, server_id: str) -> int | None:
|
||||
r = await session.execute(select(Server.tariff_group).where(Server.server_name == server_id))
|
||||
group = r.scalar_one_or_none()
|
||||
if not group:
|
||||
return None
|
||||
q = await session.execute(
|
||||
select(Tariff.device_limit)
|
||||
.where(Tariff.group_code == group, Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.duration_days.desc())
|
||||
.limit(1)
|
||||
)
|
||||
dl = q.scalar_one_or_none()
|
||||
return int(dl) if dl is not None else None
|
||||
|
||||
|
||||
async def filter_cluster_by_subgroup(
|
||||
session: AsyncSession, cluster: list, target_subgroup: str, cluster_id: str
|
||||
) -> list:
|
||||
names = [s.get("server_name") for s in cluster if s.get("server_name")]
|
||||
if not names:
|
||||
return []
|
||||
|
||||
q_allowed = await session.execute(
|
||||
select(Server.server_name)
|
||||
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
|
||||
.where(
|
||||
Server.server_name.in_(names),
|
||||
Server.enabled.is_(True),
|
||||
ServerSubgroup.subgroup_title == target_subgroup,
|
||||
)
|
||||
)
|
||||
allowed = {n for (n,) in q_allowed.all()}
|
||||
if allowed:
|
||||
return [s for s in cluster if s.get("server_name") in allowed]
|
||||
|
||||
total_for_subgroup = await session.scalar(
|
||||
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title == target_subgroup)
|
||||
)
|
||||
if not total_for_subgroup:
|
||||
logger.info(f"Для подгруппы {target_subgroup} нет ни одного сервера. Используем весь кластер {cluster_id}.")
|
||||
return cluster
|
||||
|
||||
q_any = await session.execute(
|
||||
select(Server.server_name)
|
||||
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
|
||||
.where(
|
||||
Server.server_name.in_(names),
|
||||
Server.enabled.is_(True),
|
||||
)
|
||||
)
|
||||
any_bound = {n for (n,) in q_any.all()}
|
||||
if any_bound:
|
||||
logger.warning(f"Нет серверов под подгруппу {target_subgroup} в кластере {cluster_id}. Продление пропущено.")
|
||||
return []
|
||||
|
||||
logger.info(f"В кластере {cluster_id} нет привязок подгрупп. Продлеваем по всему кластеру.")
|
||||
return cluster
|
||||
@@ -0,0 +1,141 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import and_, exists, func, not_, select
|
||||
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:
|
||||
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
|
||||
return await session.scalar(select(func.count()).select_from(Key).where(Key.tariff_id.in_(trial_tariffs_subquery)))
|
||||
|
||||
|
||||
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.status == "success",
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
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.status == "success",
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
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(
|
||||
and_(
|
||||
Payment.status == "success",
|
||||
Payment.payment_system.notin_(["referral", "coupon", "cashback"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
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.amount > 0)
|
||||
.where(Payment.status == "success")
|
||||
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
|
||||
.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,266 @@
|
||||
import hashlib
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, func, insert, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Server, Tariff
|
||||
from logger import logger
|
||||
|
||||
|
||||
def create_subgroup_hash(subgroup_title: str, group_code: str) -> str:
|
||||
if not subgroup_title:
|
||||
return ""
|
||||
|
||||
unique_key = f"{subgroup_title}:{group_code}"
|
||||
hash_object = hashlib.md5(unique_key.encode("utf-8"))
|
||||
return hash_object.hexdigest()[:8]
|
||||
|
||||
|
||||
async def find_subgroup_by_hash(session: AsyncSession, subgroup_hash: str, group_code: str) -> str | None:
|
||||
result = await session.execute(
|
||||
select(Tariff.subgroup_title)
|
||||
.where(Tariff.group_code == group_code, Tariff.subgroup_title.isnot(None))
|
||||
.distinct()
|
||||
)
|
||||
subgroups = [row[0] for row in result.fetchall()]
|
||||
|
||||
for subgroup_title in subgroups:
|
||||
if create_subgroup_hash(subgroup_title, group_code) == subgroup_hash:
|
||||
return subgroup_title
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_tariffs(
|
||||
session: AsyncSession, tariff_id: int = None, group_code: str = None, with_subgroup_weights: bool = False
|
||||
):
|
||||
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).order_by(Tariff.sort_order, Tariff.id)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(select(Tariff).order_by(Tariff.sort_order, Tariff.id))
|
||||
|
||||
tariffs = [dict(r.__dict__) for r in result.scalars().all()]
|
||||
|
||||
if with_subgroup_weights and group_code:
|
||||
tariffs_without_order = [t for t in tariffs if t.get("sort_order") is None]
|
||||
if tariffs_without_order:
|
||||
for tariff in tariffs_without_order:
|
||||
tariff["sort_order"] = 1
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff["id"]).values(sort_order=1))
|
||||
await session.commit()
|
||||
|
||||
grouped = defaultdict(list)
|
||||
for t in tariffs:
|
||||
grouped[t.get("subgroup_title")].append(t)
|
||||
|
||||
subgroup_weights = {}
|
||||
for subgroup, tariffs_list in grouped.items():
|
||||
if subgroup:
|
||||
total_weight = sum(t.get("sort_order", 1) for t in tariffs_list)
|
||||
subgroup_weights[subgroup] = total_weight
|
||||
|
||||
return {"tariffs": tariffs, "subgroup_weights": subgroup_weights}
|
||||
|
||||
return tariffs
|
||||
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.sort_order, 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()
|
||||
|
||||
if "sort_order" not in data:
|
||||
group_code = data.get("group_code")
|
||||
if group_code:
|
||||
result = await session.execute(
|
||||
select(func.max(Tariff.sort_order)).where(
|
||||
Tariff.group_code == group_code, Tariff.sort_order.isnot(None)
|
||||
)
|
||||
)
|
||||
max_order = result.scalar() or 0
|
||||
else:
|
||||
result = await session.execute(select(func.max(Tariff.sort_order)).where(Tariff.sort_order.isnot(None)))
|
||||
max_order = result.scalar() or 0
|
||||
|
||||
data["sort_order"] = max_order + 1
|
||||
|
||||
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:
|
||||
return True
|
||||
logger.warning(f"[TARIFF] Тариф {tariff_id} не найден в БД")
|
||||
return False
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при проверке тарифа {tariff_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_tariff_sort_order(session: AsyncSession, tariff_id: int) -> int:
|
||||
try:
|
||||
result = await session.execute(select(Tariff.sort_order).where(Tariff.id == tariff_id))
|
||||
sort_order = result.scalar_one_or_none()
|
||||
|
||||
if sort_order is None:
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=1))
|
||||
await session.commit()
|
||||
return 1
|
||||
|
||||
return sort_order
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при получении sort_order для тарифа {tariff_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def move_tariff_up(session: AsyncSession, tariff_id: int) -> bool:
|
||||
try:
|
||||
current_order = await get_tariff_sort_order(session, tariff_id)
|
||||
new_order = max(1, current_order - 1)
|
||||
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order))
|
||||
await session.commit()
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при перемещении тарифа {tariff_id} вверх: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def move_tariff_down(session: AsyncSession, tariff_id: int) -> bool:
|
||||
try:
|
||||
current_order = await get_tariff_sort_order(session, tariff_id)
|
||||
new_order = current_order + 1
|
||||
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order))
|
||||
await session.commit()
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при перемещении тарифа {tariff_id} вниз: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def initialize_tariff_sort_orders(session: AsyncSession, group_code: str) -> bool:
|
||||
try:
|
||||
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
if not tariffs:
|
||||
return True
|
||||
|
||||
for i, tariff in enumerate(tariffs):
|
||||
new_sort_order = 1 + i
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff.id).values(sort_order=new_sort_order))
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при инициализации sort_order для группы {group_code}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def initialize_all_tariff_weights(session: AsyncSession) -> bool:
|
||||
try:
|
||||
result = await session.execute(select(Tariff).where(Tariff.sort_order.is_(None)))
|
||||
tariffs_without_weight = result.scalars().all()
|
||||
|
||||
if not tariffs_without_weight:
|
||||
return True
|
||||
|
||||
for tariff in tariffs_without_weight:
|
||||
await session.execute(update(Tariff).where(Tariff.id == tariff.id).values(sort_order=1))
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[TARIFF] Ошибка при инициализации весов тарифов: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
@@ -0,0 +1,42 @@
|
||||
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,252 @@
|
||||
from sqlalchemy import and_, func, insert, not_, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Payment, TrackingSource, User
|
||||
from logger import logger
|
||||
|
||||
|
||||
EXCLUDED_PAYMENT_MARKERS = ["coupon", "referral", "cashback"]
|
||||
|
||||
|
||||
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:
|
||||
def _month_key(dt) -> str:
|
||||
return dt.strftime("%Y-%m")
|
||||
|
||||
src_row = await session.execute(
|
||||
select(TrackingSource.name, TrackingSource.code, TrackingSource.created_at).where(TrackingSource.code == code)
|
||||
)
|
||||
src = src_row.first()
|
||||
if not src:
|
||||
return None
|
||||
|
||||
_src_name, _src_code, created_at = src
|
||||
|
||||
reg_subq = (
|
||||
select(func.count(func.distinct(User.tg_id)))
|
||||
.where((User.source_code == code) & (User.created_at >= created_at))
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
trial_subq = (
|
||||
select(func.count(func.distinct(User.tg_id)))
|
||||
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
|
||||
.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_(EXCLUDED_PAYMENT_MARKERS))
|
||||
& (Payment.created_at >= created_at)
|
||||
)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
amount_subq = (
|
||||
select(func.coalesce(func.sum(Payment.amount), 0.0))
|
||||
.join(User, Payment.tg_id == User.tg_id)
|
||||
.where(
|
||||
(User.source_code == code)
|
||||
& (Payment.status == "success")
|
||||
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
|
||||
& (Payment.created_at >= created_at)
|
||||
)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
header_q = 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)
|
||||
|
||||
header_res = await session.execute(header_q)
|
||||
row = header_res.first()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
payments_base = (
|
||||
select(
|
||||
Payment.tg_id.label("tg_id"),
|
||||
Payment.amount.label("amount"),
|
||||
Payment.created_at.label("dt"),
|
||||
)
|
||||
.join(User, Payment.tg_id == User.tg_id)
|
||||
.where(
|
||||
(User.source_code == code)
|
||||
& (Payment.status == "success")
|
||||
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
|
||||
& (Payment.created_at >= created_at)
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
first_pay = (
|
||||
select(
|
||||
payments_base.c.tg_id.label("tg_id"),
|
||||
func.min(payments_base.c.dt).label("first_dt"),
|
||||
)
|
||||
.group_by(payments_base.c.tg_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
month_expr_new = func.date_trunc("month", payments_base.c.dt).label("month")
|
||||
new_rows = await session.execute(
|
||||
select(
|
||||
month_expr_new,
|
||||
func.count().label("cnt"),
|
||||
func.coalesce(func.sum(payments_base.c.amount), 0.0).label("amt"),
|
||||
)
|
||||
.join(
|
||||
first_pay,
|
||||
and_(
|
||||
payments_base.c.tg_id == first_pay.c.tg_id,
|
||||
payments_base.c.dt == first_pay.c.first_dt,
|
||||
),
|
||||
)
|
||||
.group_by(month_expr_new)
|
||||
.order_by(month_expr_new)
|
||||
)
|
||||
new_by_month = {r.month: (int(r.cnt), float(r.amt)) for r in new_rows.all()}
|
||||
|
||||
month_expr_rep = func.date_trunc("month", payments_base.c.dt).label("month")
|
||||
repeat_rows = await session.execute(
|
||||
select(
|
||||
month_expr_rep,
|
||||
func.count().label("cnt"),
|
||||
func.coalesce(func.sum(payments_base.c.amount), 0.0).label("amt"),
|
||||
)
|
||||
.join(first_pay, payments_base.c.tg_id == first_pay.c.tg_id)
|
||||
.where(payments_base.c.dt > first_pay.c.first_dt)
|
||||
.group_by(month_expr_rep)
|
||||
.order_by(month_expr_rep)
|
||||
)
|
||||
repeat_by_month = {r.month: (int(r.cnt), float(r.amt)) for r in repeat_rows.all()}
|
||||
|
||||
month_expr_regs = func.date_trunc("month", User.created_at).label("month")
|
||||
regs_rows = await session.execute(
|
||||
select(
|
||||
month_expr_regs,
|
||||
func.count(func.distinct(User.tg_id)).label("cnt"),
|
||||
)
|
||||
.where((User.source_code == code) & (User.created_at >= created_at))
|
||||
.group_by(month_expr_regs)
|
||||
.order_by(month_expr_regs)
|
||||
)
|
||||
regs_by_month = {r.month: int(r.cnt) for r in regs_rows.all()}
|
||||
|
||||
month_expr_trials = func.date_trunc("month", User.created_at).label("month")
|
||||
trials_rows = await session.execute(
|
||||
select(
|
||||
month_expr_trials,
|
||||
func.count(func.distinct(User.tg_id)).label("cnt"),
|
||||
)
|
||||
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
|
||||
.group_by(month_expr_trials)
|
||||
.order_by(month_expr_trials)
|
||||
)
|
||||
trials_by_month = {r.month: int(r.cnt) for r in trials_rows.all()}
|
||||
|
||||
months = set()
|
||||
months.update(regs_by_month.keys())
|
||||
months.update(trials_by_month.keys())
|
||||
months.update(new_by_month.keys())
|
||||
months.update(repeat_by_month.keys())
|
||||
|
||||
monthly = []
|
||||
for m in sorted(months):
|
||||
regs = regs_by_month.get(m, 0)
|
||||
trls = trials_by_month.get(m, 0)
|
||||
new_cnt, new_amt = new_by_month.get(m, (0, 0.0))
|
||||
rep_cnt, rep_amt = repeat_by_month.get(m, (0, 0.0))
|
||||
monthly.append({
|
||||
"month": _month_key(m),
|
||||
"registrations": regs,
|
||||
"trials": trls,
|
||||
"new_purchases_count": new_cnt,
|
||||
"new_purchases_amount": new_amt,
|
||||
"repeat_purchases_count": rep_cnt,
|
||||
"repeat_purchases_amount": rep_amt,
|
||||
})
|
||||
|
||||
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),
|
||||
"monthly": monthly,
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, exists, func, 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,
|
||||
GiftUsage,
|
||||
Key,
|
||||
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:
|
||||
res = await session.execute(
|
||||
update(User)
|
||||
.where(User.tg_id == tg_id)
|
||||
.values(balance=func.coalesce(User.balance, 0) + amount)
|
||||
.returning(User.balance)
|
||||
)
|
||||
new_balance = res.scalar_one_or_none()
|
||||
await session.commit()
|
||||
if new_balance is not None:
|
||||
old_balance = new_balance - amount
|
||||
logger.info(f"[DB] Баланс пользователя {tg_id} обновлён: {old_balance} → {new_balance}")
|
||||
else:
|
||||
logger.info(f"[DB] Баланс пользователя {tg_id} не изменён: пользователь не найден")
|
||||
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(func.coalesce(User.balance, 0.0)).where(User.tg_id == tg_id))
|
||||
return round(float(result.scalar_one()), 1)
|
||||
|
||||
|
||||
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(func.coalesce(User.trial, 0)).where(User.tg_id == tg_id))
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
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(),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
result = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||
return dict(result.scalar_one().__dict__)
|
||||
else:
|
||||
res = 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(),
|
||||
},
|
||||
)
|
||||
.returning(User)
|
||||
)
|
||||
obj = res.scalar_one()
|
||||
await session.commit()
|
||||
d = obj.__dict__.copy()
|
||||
d.pop("_sa_instance_state", None)
|
||||
return d
|
||||
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(GiftUsage).where(GiftUsage.gift_id.in_(select(Gift.gift_id).where(Gift.sender_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:
|
||||
await session.rollback()
|
||||
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
|
||||
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()
|
||||
|
||||
|
||||
async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int] | None:
|
||||
res = await session.execute(
|
||||
select(func.coalesce(User.trial, 0), func.count(Key.client_id))
|
||||
.select_from(User)
|
||||
.join(Key, Key.tg_id == User.tg_id, isouter=True)
|
||||
.where(User.tg_id == tg_id)
|
||||
.group_by(User.tg_id, User.trial)
|
||||
)
|
||||
row = res.first()
|
||||
if row is None:
|
||||
return None
|
||||
return int(row[0]), int(row[1])
|
||||
|
||||
|
||||
async def upsert_source_if_empty(session: AsyncSession, tg_id: int, source_code: str) -> None:
|
||||
if not source_code:
|
||||
return
|
||||
stmt = (
|
||||
insert(User)
|
||||
.values(tg_id=tg_id, source_code=source_code)
|
||||
.on_conflict_do_update(
|
||||
index_elements=[User.tg_id],
|
||||
set_={"source_code": insert(User).excluded.source_code},
|
||||
where=(User.source_code.is_(None)),
|
||||
)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
+9
-8
@@ -1,16 +1,17 @@
|
||||
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import ADMIN_ID
|
||||
from database.db import async_session_maker
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
class IsAdminFilter(BaseFilter):
|
||||
async def __call__(self, message: Message) -> bool:
|
||||
async def __call__(self, event: Message | CallbackQuery) -> bool:
|
||||
try:
|
||||
admin_ids: int | list[int] = ADMIN_ID
|
||||
if isinstance(admin_ids, list):
|
||||
return message.from_user.id in admin_ids
|
||||
return message.from_user.id == admin_ids
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == event.from_user.id))
|
||||
admin = result.scalar_one_or_none()
|
||||
return admin is not None
|
||||
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
|
||||
@@ -3,22 +3,24 @@ __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,
|
||||
@@ -26,4 +28,5 @@ router.include_routers(
|
||||
keys_router,
|
||||
instructions_router,
|
||||
admin_router,
|
||||
refferal_router,
|
||||
)
|
||||
|
||||
@@ -2,16 +2,39 @@ __all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .admin_coupons import router as coupons_router
|
||||
from .admin_panel import router as panel_router
|
||||
from .admin_servers import router as servers_router
|
||||
from .admin_user_editor import router as user_editor_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 .gifts import router as gifts_router
|
||||
from .management import router as management_router
|
||||
from .module import router as modules_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(
|
||||
modules_router,
|
||||
panel_router,
|
||||
management_router,
|
||||
servers_router,
|
||||
clusters_router,
|
||||
users_router,
|
||||
stats_router,
|
||||
backups_router,
|
||||
sender_router,
|
||||
coupons_router,
|
||||
user_editor_router,
|
||||
restart_router,
|
||||
bans_router,
|
||||
ads_router,
|
||||
tariffs_router,
|
||||
gifts_router,
|
||||
)
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from database import create_coupon, delete_coupon_from_db, get_all_coupons
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
|
||||
class AdminCouponsState(StatesGroup):
|
||||
waiting_for_coupon_data = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupons_editor", IsAdminFilter())
|
||||
async def show_coupon_management_menu(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
await state.clear()
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="➕ Создать купон", callback_data="create_coupon")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="Купоны", callback_data="coupons"))
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
|
||||
await callback_query.message.answer(
|
||||
"🛠 Меню управления купонами:", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupons", IsAdminFilter())
|
||||
async def show_coupon_list(callback_query: types.CallbackQuery, session: Any):
|
||||
try:
|
||||
coupons = await get_all_coupons(session)
|
||||
|
||||
if not coupons:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
"❌ На данный момент нет доступных купонов. 🚫\nВы можете вернуться в меню управления. 🔙",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
coupon_list = "📜 Список всех купонов:\n\n"
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for coupon in coupons:
|
||||
coupon_list += (
|
||||
f"🏷️ <b>Код:</b> {coupon['code']}\n"
|
||||
f"💰 <b>Сумма:</b> {coupon['amount']} рублей\n"
|
||||
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
|
||||
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n\n"
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"❌ Удалить {coupon['code']}",
|
||||
callback_data=f"delete_coupon_{coupon['code']}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
coupon_list, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении списка купонов: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_coupon_"), IsAdminFilter())
|
||||
async def handle_delete_coupon(callback_query: types.CallbackQuery, session: Any):
|
||||
coupon_code = callback_query.data[len("delete_coupon_") :]
|
||||
|
||||
try:
|
||||
result = await delete_coupon_from_db(coupon_code, session)
|
||||
|
||||
if result:
|
||||
await show_coupon_list(callback_query, session)
|
||||
else:
|
||||
await callback_query.message.answer(
|
||||
f"❌ Купон с кодом <b>{coupon_code}</b> не найден.",
|
||||
)
|
||||
await show_coupon_list(callback_query, session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении купона: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "create_coupon", IsAdminFilter())
|
||||
async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(AdminCouponsState.waiting_for_coupon_data)
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
|
||||
async def process_coupon_data(message: types.Message, state: FSMContext, session: Any):
|
||||
text = message.text.strip()
|
||||
|
||||
parts = text.split()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
|
||||
|
||||
if len(parts) != 3:
|
||||
await message.answer(
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
coupon_code = parts[0]
|
||||
coupon_amount = float(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"⚠️ <b>Проверьте правильность введенных данных!</b>\n"
|
||||
"💱 Сумма должна быть числом, 🔢 а лимит — целым числом.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(coupon_code, coupon_amount, usage_limit, session)
|
||||
|
||||
result_message = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан! 🎉\n"
|
||||
f"Сумма: <b>{coupon_amount} рублей</b> 💰\n"
|
||||
f"Лимит использования: <b>{usage_limit} раз</b> 🔢."
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
|
||||
)
|
||||
|
||||
await message.answer(result_message, reply_markup=builder.as_markup())
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
@@ -1,391 +0,0 @@
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from backup import backup_database
|
||||
from bot import bot
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
waiting_for_tg_id = State()
|
||||
displaying_user_info = State()
|
||||
waiting_for_restart_confirmation = State()
|
||||
waiting_for_message = State()
|
||||
|
||||
|
||||
@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"), F.data == "admin", IsAdminFilter())
|
||||
async def handle_admin_message(message: types.Message, state: FSMContext):
|
||||
await state.clear()
|
||||
|
||||
BOT_VERSION = "3.2.4-beta" # Укажите текущую версию бота
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика пользователей", callback_data="user_stats"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="👥 Управление пользователями", callback_data="user_editor"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🖥️ Управление серверами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🎟️ Управление купонами", callback_data="coupons_editor"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📢 Массовая рассылка", callback_data="send_to_alls")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Управление Ботом", callback_data="bot_management"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
|
||||
)
|
||||
await message.answer(
|
||||
f"🤖 Панель администратора\n\nВерсия бота: <b>{BOT_VERSION}</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "bot_management")
|
||||
async def handle_bot_management(callback_query: types.CallbackQuery):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="💾 Создать резервную копию", callback_data="backups")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin")
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
"🤖 Управление ботом",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "user_stats", IsAdminFilter())
|
||||
async def user_stats_menu(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
total_users = await session.fetchval("SELECT COUNT(*) FROM connections")
|
||||
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
|
||||
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
|
||||
|
||||
total_payments_today = await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE"
|
||||
)
|
||||
total_payments_week = await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
|
||||
)
|
||||
total_payments_month = await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
|
||||
)
|
||||
total_payments_all_time = await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments"
|
||||
)
|
||||
|
||||
active_keys = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
)
|
||||
expired_keys = total_keys - active_keys
|
||||
|
||||
stats_message = (
|
||||
f"📊 <b>Подробная статистика проекта:</b>\n\n"
|
||||
f"👥 Пользователи:\n"
|
||||
f" 🌐 Зарегистрировано: <b>{total_users}</b>\n"
|
||||
f" 🤝 Привлеченных рефералов: <b>{total_referrals}</b>\n\n"
|
||||
f"🔑 Ключи:\n"
|
||||
f" 🌈 Всего сгенерировано: <b>{total_keys}</b>\n"
|
||||
f" ✅ Действующих: <b>{active_keys}</b>\n"
|
||||
f" ❌ Просроченных: <b>{expired_keys}</b>\n\n"
|
||||
f"💰 Финансовая статистика:\n"
|
||||
f" 📅 За день: <b>{total_payments_today} ₽</b>\n"
|
||||
f" 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
|
||||
f" 📆 За месяц: <b>{total_payments_month} ₽</b>\n"
|
||||
f" 🏦 За все время: <b>{total_payments_all_time} ₽</b>\n"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="user_stats")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📥 Выгрузить пользователей в CSV",
|
||||
callback_data="export_users_csv",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📥 Выгрузить оплаты в CSV", callback_data="export_payments_csv"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin")
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
stats_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in user_stats_menu: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "export_users_csv", IsAdminFilter())
|
||||
async def export_users_csv(callback_query: CallbackQuery, session: Any):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
|
||||
try:
|
||||
users = await session.fetch(
|
||||
"""
|
||||
SELECT
|
||||
u.tg_id,
|
||||
u.username,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.language_code,
|
||||
u.is_bot,
|
||||
c.balance,
|
||||
c.trial
|
||||
FROM users u
|
||||
LEFT JOIN connections c ON u.tg_id = c.tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
if not users:
|
||||
await callback_query.message.answer(
|
||||
"📭 Нет пользователей для экспорта.", reply_markup=builder.as_markup()
|
||||
)
|
||||
return
|
||||
|
||||
csv_data = "tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n"
|
||||
for user in users:
|
||||
csv_data += f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']},{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n"
|
||||
|
||||
file_name = BytesIO(csv_data.encode("utf-8-sig"))
|
||||
file_name.seek(0)
|
||||
|
||||
file = BufferedInputFile(file_name.getvalue(), filename="users_export.csv")
|
||||
|
||||
await callback_query.message.answer_document(
|
||||
file,
|
||||
caption="📥 Экспорт пользователей в CSV",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
file_name.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
|
||||
await callback_query.message.answer(
|
||||
"❗ Произошла ошибка при экспорте пользователей.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "export_payments_csv", IsAdminFilter())
|
||||
async def export_payments_csv(callback_query: CallbackQuery, session: Any):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
|
||||
try:
|
||||
payments = await session.fetch(
|
||||
"""
|
||||
SELECT
|
||||
u.tg_id,
|
||||
u.username,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
p.amount,
|
||||
p.payment_system,
|
||||
p.status,
|
||||
p.created_at
|
||||
FROM users u
|
||||
JOIN payments p ON u.tg_id = p.tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
if not payments:
|
||||
await callback_query.message.answer(
|
||||
"📭 Нет платежей для экспорта.", reply_markup=builder.as_markup()
|
||||
)
|
||||
return
|
||||
|
||||
csv_data = "tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n" # Заголовки CSV
|
||||
for payment in payments:
|
||||
csv_data += f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']},{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n"
|
||||
|
||||
file_name = BytesIO(csv_data.encode("utf-8-sig"))
|
||||
file_name.seek(0)
|
||||
|
||||
file = BufferedInputFile(file_name.getvalue(), filename="payments_export.csv")
|
||||
|
||||
await callback_query.message.answer_document(
|
||||
file, caption="📥 Экспорт платежей в CSV", reply_markup=builder.as_markup()
|
||||
)
|
||||
file_name.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
|
||||
await callback_query.message.answer(
|
||||
"❗ Произошла ошибка при экспорте платежей.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "send_to_alls", IsAdminFilter())
|
||||
async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
|
||||
await callback_query.message.answer(
|
||||
"✍️ Введите текст сообщения, который вы хотите отправить всем клиентам 📢🌐:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_message)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_message, IsAdminFilter())
|
||||
async def process_message_to_all(
|
||||
message: types.Message, state: FSMContext, session: Any
|
||||
):
|
||||
text_message = message.text
|
||||
|
||||
try:
|
||||
tg_ids = await session.fetch("SELECT tg_id FROM connections")
|
||||
|
||||
total_users = len(tg_ids)
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
for record in tg_ids:
|
||||
tg_id = record["tg_id"]
|
||||
try:
|
||||
await bot.send_message(chat_id=tg_id, text=text_message)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f"❌ Ошибка при отправке сообщения пользователю {tg_id}: {e}"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"📤 Рассылка завершена:\n"
|
||||
f"👥 Всего пользователей: {total_users}\n"
|
||||
f"✅ Успешно отправлено: {success_count}\n"
|
||||
f"❌ Не доставлено: {error_count}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❗ Ошибка при подключении к базе данных: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "backups", IsAdminFilter())
|
||||
async def handle_backup(callback_query: CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.answer(
|
||||
"💾 Инициализация резервного копирования базы данных..."
|
||||
)
|
||||
await backup_database()
|
||||
await callback_query.message.answer(
|
||||
"✅ Резервная копия успешно создана и отправлена администратору."
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "restart_bot", IsAdminFilter())
|
||||
async def handle_restart(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(UserEditorState.waiting_for_restart_confirmation)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да, перезапустить", callback_data="confirm_restart"
|
||||
),
|
||||
InlineKeyboardButton(text="❌ Нет, отмена", callback_data="admin"),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
|
||||
await callback_query.message.answer(
|
||||
"🤔 Вы уверены, что хотите перезапустить бота?",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data == "confirm_restart",
|
||||
UserEditorState.waiting_for_restart_confirmation,
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def confirm_restart_bot(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "systemctl", "restart", "bot.service"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
await state.clear()
|
||||
await callback_query.message.answer(
|
||||
"🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
await callback_query.message.answer(
|
||||
"🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
|
||||
)
|
||||
except Exception as e:
|
||||
await callback_query.message.answer(
|
||||
f"⚠️ Ошибка при перезагрузке бота: {e.stderr}",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "user_editor", IsAdminFilter())
|
||||
async def user_editor_menu(callback_query: CallbackQuery):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔍 Поиск по названию ключа",
|
||||
callback_data="search_by_key_name",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🆔 Поиск по Telegram ID", callback_data="search_by_tg_id"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Поиск по Username", callback_data="search_by_username"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Вернуться назад", callback_data="admin"))
|
||||
await callback_query.message.answer(
|
||||
"👇 Выберите способ поиска пользователя:", reply_markup=builder.as_markup()
|
||||
)
|
||||
@@ -1,531 +0,0 @@
|
||||
import asyncpg
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from backup import create_backup_and_send_to_admins
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL
|
||||
from database import check_unique_server_name, get_servers_from_db
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
waiting_for_cluster_name = State()
|
||||
waiting_for_api_url = State()
|
||||
waiting_for_inbound_id = State()
|
||||
waiting_for_server_name = State()
|
||||
waiting_for_subscription_url = State()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "servers_editor", IsAdminFilter())
|
||||
async def handle_servers_editor(callback_query: types.CallbackQuery):
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"⚙️ {cluster_name}", callback_data=f"manage_cluster|{cluster_name}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="➕ Добавить кластер", callback_data="add_cluster")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад в админку", callback_data="admin"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
"<b>🔧 Управление кластерами</b>\n\n"
|
||||
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
|
||||
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
|
||||
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
|
||||
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "add_cluster", IsAdminFilter())
|
||||
async def handle_add_cluster(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.answer(
|
||||
"🔧 <b>Введите имя нового кластера:</b>\n\n"
|
||||
"<b>Имя кластера должно быть уникальным и на английском языке.</b>\n"
|
||||
"<i>Пример:</i> <code>cluster2</code> или <code>us_east_1</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(UserEditorState.waiting_for_cluster_name)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_cluster_name, IsAdminFilter())
|
||||
async def handle_cluster_name_input(message: types.Message, state: FSMContext):
|
||||
cluster_name = message.text.strip()
|
||||
|
||||
if cluster_name == "❌ Отменить":
|
||||
await state.clear()
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
await message.answer(
|
||||
"Процесс создания кластера отменен. Вы вернулись в меню управления серверами.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if not cluster_name:
|
||||
await message.answer("❌ Имя кластера не может быть пустым. Попробуйте снова.")
|
||||
return
|
||||
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_server_name, IsAdminFilter())
|
||||
async def handle_server_name_input(message: types.Message, state: FSMContext):
|
||||
server_name = message.text.strip()
|
||||
|
||||
if server_name == "❌ Отменить":
|
||||
await state.clear()
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
await message.answer(
|
||||
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if not server_name:
|
||||
await message.answer("❌ Имя сервера не может быть пустым. Попробуйте снова.")
|
||||
return
|
||||
|
||||
server_unique = await check_unique_server_name(server_name)
|
||||
if not server_unique:
|
||||
await message.answer(
|
||||
"❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя."
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
await state.update_data(server_name=server_name)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"API URL должен быть в следующем формате:\n\n"
|
||||
"<code>https://your_domain:port/panel_path</code>\n\n"
|
||||
"URL должен быть без слэша на конце!\n",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_api_url)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_api_url, IsAdminFilter())
|
||||
async def handle_api_url_input(message: types.Message, state: FSMContext):
|
||||
api_url = message.text.strip()
|
||||
|
||||
if api_url == "❌ Отменить":
|
||||
await state.clear()
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
await message.answer(
|
||||
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if not api_url.startswith("https://"):
|
||||
await message.answer(
|
||||
"❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
api_url = api_url.rstrip("/")
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
await state.update_data(api_url=api_url)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"Subscription URL должен быть в следующем формате:\n\n"
|
||||
"<code>https://your_domain:port_sub/sub_path</code>\n\n"
|
||||
"URL должен быть без слэша и имени клиента на конце!\n"
|
||||
"Его можно увидеть в панели 3x-ui в информации о клиенте.",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_subscription_url)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_subscription_url, IsAdminFilter())
|
||||
async def handle_subscription_url_input(message: types.Message, state: FSMContext):
|
||||
subscription_url = message.text.strip()
|
||||
|
||||
if subscription_url == "❌ Отменить":
|
||||
await state.clear()
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
await message.answer(
|
||||
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if not subscription_url.startswith("https://"):
|
||||
await message.answer(
|
||||
"❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
subscription_url = subscription_url.rstrip("/")
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
await state.update_data(subscription_url=subscription_url)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"Это номер подключения vless в вашей панели 3x-ui. Обычно это <b>1</b> при чистой настройке по гайду.\n\n",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_inbound_id)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_inbound_id, IsAdminFilter())
|
||||
async def handle_inbound_id_input(message: types.Message, state: FSMContext):
|
||||
inbound_id = message.text.strip()
|
||||
|
||||
if not inbound_id.isdigit():
|
||||
await message.answer(
|
||||
"❌ inbound_id должен быть числовым значением. Попробуйте снова."
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
api_url = user_data.get("api_url")
|
||||
subscription_url = user_data.get("subscription_url")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
cluster_name,
|
||||
server_name,
|
||||
api_url,
|
||||
subscription_url,
|
||||
inbound_id,
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад к кластерам", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("manage_cluster|"), IsAdminFilter())
|
||||
async def handle_manage_cluster(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
cluster_name = callback_query.data.split("|")[1]
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for server in cluster_servers:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"🌍 {server['server_name']}",
|
||||
callback_data=f"manage_server|{server['server_name']}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Добавить сервер", callback_data=f"add_server|{cluster_name}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Доступность серверов",
|
||||
callback_data=f"server_availability|{cluster_name}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💾 Создать бэкап кластера",
|
||||
callback_data=f"backup_cluster|{cluster_name}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"🔧 Управление серверами для кластера {cluster_name}",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("server_availability|"), IsAdminFilter())
|
||||
async def handle_check_server_availability(callback_query: types.CallbackQuery):
|
||||
cluster_name = callback_query.data.split("|")[1]
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
if not cluster_servers:
|
||||
await callback_query.answer(f"Кластер '{cluster_name}' не содержит серверов.")
|
||||
return
|
||||
|
||||
in_progress_message = await callback_query.message.answer(
|
||||
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
|
||||
"Это может занять до 1 минуты, пожалуйста, подождите..."
|
||||
)
|
||||
|
||||
availability_message = (
|
||||
f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n"
|
||||
)
|
||||
|
||||
for server in cluster_servers:
|
||||
xui = AsyncApi(
|
||||
server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
await xui.login()
|
||||
|
||||
online_users = len(await xui.client.online())
|
||||
availability_message += (
|
||||
f"🌍 {server['server_name']}: {online_users} активных пользователей.\n"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
availability_message += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
|
||||
)
|
||||
)
|
||||
|
||||
await in_progress_message.edit_text(
|
||||
availability_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("manage_server|"), IsAdminFilter())
|
||||
async def handle_manage_server(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
server_name = callback_query.data.split("|")[1]
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
server = None
|
||||
cluster_name = None
|
||||
for cluster, cluster_servers in servers.items():
|
||||
server = next(
|
||||
(s for s in cluster_servers if s["server_name"] == server_name), None
|
||||
)
|
||||
if server:
|
||||
cluster_name = cluster
|
||||
break
|
||||
|
||||
if server:
|
||||
api_url = server["api_url"]
|
||||
subscription_url = server["subscription_url"]
|
||||
inbound_id = server["inbound_id"]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить", callback_data=f"delete_server|{server_name}"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
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}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer("❌ Сервер не найден.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_server|"), IsAdminFilter())
|
||||
async def handle_delete_server(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
server_name = callback_query.data.split("|")[1]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да", callback_data=f"confirm_delete_server|{server_name}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="❌ Нет", callback_data=f"manage_server|{server_name}"
|
||||
),
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete_server|"), IsAdminFilter())
|
||||
async def handle_confirm_delete_server(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
server_name = callback_query.data.split("|")[1]
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM servers WHERE server_name = $1
|
||||
""",
|
||||
server_name,
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"🗑️ Сервер {server_name} успешно удален.", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("add_server|"), IsAdminFilter())
|
||||
async def handle_add_server(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
cluster_name = callback_query.data.split("|")[1]
|
||||
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.set_state(UserEditorState.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("backup_cluster|"), IsAdminFilter())
|
||||
async def handle_backup_cluster(callback_query: types.CallbackQuery):
|
||||
cluster_name = callback_query.data.split("|")[1]
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
for server in cluster_servers:
|
||||
xui = AsyncApi(
|
||||
server["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
await create_backup_and_send_to_admins(xui)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
|
||||
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback_query.answer()
|
||||
@@ -1,712 +0,0 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import TOTAL_GB
|
||||
from database import delete_user_data, get_client_id_by_email, get_servers_from_db, restore_trial, update_key_expiry
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.key_utils import (
|
||||
delete_key_from_cluster,
|
||||
delete_key_from_db,
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from handlers.utils import sanitize_key_name
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
waiting_for_tg_id = State()
|
||||
waiting_for_username = State()
|
||||
displaying_user_info = State()
|
||||
waiting_for_new_balance = State()
|
||||
waiting_for_key_name = State()
|
||||
waiting_for_expiry_time = State()
|
||||
waiting_for_message_text = State()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "search_by_tg_id", IsAdminFilter())
|
||||
async def prompt_tg_id(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"🔍 Введите Telegram ID клиента:", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_tg_id)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "search_by_username", IsAdminFilter())
|
||||
async def prompt_username(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"🔍 Введите Username клиента:", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_username)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_username, IsAdminFilter())
|
||||
async def handle_username_input(
|
||||
message: types.Message, state: FSMContext, session: Any
|
||||
):
|
||||
# Extract the username from a message text by removing leading '@' and the Telegram URL prefix
|
||||
username = message.text.strip().lstrip('@').replace('https://t.me/', '')
|
||||
user_record = await session.fetchrow(
|
||||
"SELECT tg_id FROM users WHERE username = $1", username
|
||||
)
|
||||
|
||||
if not user_record:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"🔍 Пользователь с указанным username не найден. 🚫",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tg_id = user_record["tg_id"]
|
||||
username = await session.fetchval(
|
||||
"SELECT username FROM users WHERE tg_id = $1", tg_id
|
||||
)
|
||||
balance = await session.fetchval(
|
||||
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
|
||||
)
|
||||
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
|
||||
referral_count = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
|
||||
)
|
||||
|
||||
if balance is None:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"🚫 Пользователь с указанным tg_id не найден. 🔍",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for (email,) in key_records:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📝 Изменить баланс",
|
||||
callback_data=f"change_balance_{tg_id}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Восстановить пробник",
|
||||
callback_data=f"restore_trial_{tg_id}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✉️ Отправить сообщение",
|
||||
callback_data=f"send_message_{tg_id}"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
|
||||
user_info = (
|
||||
f"📊 Информация о пользователе:\n\n"
|
||||
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
|
||||
f"👤 Логин пользователя: <b>@{username}</b>\n"
|
||||
f"💰 Баланс: <b>{balance}</b>\n"
|
||||
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
|
||||
f"🔑 Ключи (для редактирования нажмите на ключ):"
|
||||
)
|
||||
await message.answer(user_info, reply_markup=builder.as_markup())
|
||||
await state.set_state(UserEditorState.displaying_user_info)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("send_message_"))
|
||||
async def handle_send_message(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
tg_id = callback_query.data.split("_")[2]
|
||||
await state.update_data(target_tg_id=tg_id)
|
||||
await callback_query.message.answer(
|
||||
"✉️ Введите текст сообщения, которое вы хотите отправить пользователю."
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_message_text)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter())
|
||||
async def process_send_message(message: types.Message, state: FSMContext, bot: Bot):
|
||||
data = await state.get_data()
|
||||
target_tg_id = data.get("target_tg_id")
|
||||
|
||||
if not target_tg_id:
|
||||
await message.answer("🚫 Ошибка: ID пользователя не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
await bot.send_message(chat_id=target_tg_id, text=message.text)
|
||||
await message.answer("✅ Сообщение успешно отправлено.")
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Не удалось отправить сообщение: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_tg_id, F.text.isdigit(), IsAdminFilter())
|
||||
async def handle_tg_id_input(message: types.Message, state: FSMContext, session: Any):
|
||||
tg_id = int(message.text)
|
||||
username = await session.fetchval(
|
||||
"SELECT username FROM users WHERE tg_id = $1", tg_id
|
||||
)
|
||||
balance = await session.fetchval(
|
||||
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
|
||||
)
|
||||
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
|
||||
referral_count = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
|
||||
)
|
||||
|
||||
if balance is None:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"❌ Пользователь с указанным tg_id не найден. 🔍",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for (email,) in key_records:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📝 Изменить баланс",
|
||||
callback_data=f"change_balance_{tg_id}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Восстановить пробник",
|
||||
callback_data=f"restore_trial_{tg_id}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✉️ Отправить сообщение",
|
||||
callback_data=f"send_message_{tg_id}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
|
||||
user_info = (
|
||||
f"📊 Информация о пользователе:\n\n"
|
||||
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
|
||||
f"👤 Логин пользователя: <b>@{username}</b>\n"
|
||||
f"💰 Баланс: <b>{balance}</b>\n"
|
||||
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
|
||||
f"🔑 Ключи (для редактирования нажмите на ключ):"
|
||||
)
|
||||
await message.answer(user_info, reply_markup=builder.as_markup())
|
||||
await state.set_state(UserEditorState.displaying_user_info)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("restore_trial_"), IsAdminFilter())
|
||||
async def handle_restore_trial(callback_query: types.CallbackQuery, session: Any):
|
||||
tg_id = int(callback_query.data.split("_")[2])
|
||||
|
||||
await restore_trial(tg_id, session)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в меню администратора", callback_data="admin"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
"✅ Триал успешно восстановлен.", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("change_balance_"), IsAdminFilter())
|
||||
async def process_balance_change(callback_query: CallbackQuery, state: FSMContext):
|
||||
tg_id = int(callback_query.data.split("_")[2])
|
||||
await state.update_data(tg_id=tg_id)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"💸 Введите новую сумму баланса:", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_new_balance)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_new_balance, IsAdminFilter())
|
||||
async def handle_new_balance_input(
|
||||
message: types.Message, state: FSMContext, session: Any
|
||||
):
|
||||
if not message.text.isdigit() or int(message.text) < 0:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"❌ Пожалуйста, введите корректную сумму для изменения баланса.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
new_balance = int(message.text)
|
||||
user_data = await state.get_data()
|
||||
tg_id = user_data.get("tg_id")
|
||||
|
||||
await session.execute(
|
||||
"UPDATE connections SET balance = $1 WHERE tg_id = $2",
|
||||
new_balance,
|
||||
tg_id,
|
||||
)
|
||||
|
||||
response_message = f"✅ Баланс успешно изменен на <b>{new_balance}</b>."
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в меню администратора",
|
||||
callback_data="admin",
|
||||
)
|
||||
)
|
||||
await message.answer(response_message, reply_markup=builder.as_markup())
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def get_key_details(email, session):
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT k.key, k.expiry_time, k.server_id, c.tg_id, c.balance
|
||||
FROM keys k
|
||||
JOIN connections c ON k.tg_id = c.tg_id
|
||||
WHERE k.email = $1
|
||||
""",
|
||||
email,
|
||||
)
|
||||
|
||||
if not record:
|
||||
return None
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
cluster_name = "Неизвестный кластер"
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
if any(
|
||||
server["inbound_id"] == record["server_id"] for server in cluster_servers
|
||||
):
|
||||
cluster_name = cluster_name
|
||||
break
|
||||
|
||||
expiry_date = datetime.utcfromtimestamp(record["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": record["key"],
|
||||
"expiry_date": expiry_date.strftime("%d %B %Y года"),
|
||||
"days_left_message": days_left_message,
|
||||
"server_name": cluster_name,
|
||||
"balance": record["balance"],
|
||||
"tg_id": record["tg_id"],
|
||||
}
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_key_"), IsAdminFilter())
|
||||
async def process_key_edit(callback_query: CallbackQuery, session: Any):
|
||||
email = callback_query.data.split("_", 2)[2]
|
||||
key_details = await get_key_details(email, session)
|
||||
|
||||
if not key_details:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"🔍 <b>Информация о ключе не найдена.</b> 🚫",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
response_message = (
|
||||
f"🔑 Ключ: <code>{key_details['key']}</code>\n"
|
||||
f"⏰ Дата истечения: <b>{key_details['expiry_date']}</b>\n"
|
||||
f"💰 Баланс пользователя: <b>{key_details['balance']}</b>\n"
|
||||
f"🌐 Кластер: <b>{key_details['server_name']}</b>"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="ℹ️ Получить информацию о юзере",
|
||||
callback_data=f"user_info|{key_details['tg_id']}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Изменить время истечения",
|
||||
callback_data=f"change_expiry|{email}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="❌ Удалить ключ",
|
||||
callback_data=f"delete_key_admin|{email}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
response_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "search_by_key_name", IsAdminFilter())
|
||||
async def prompt_key_name(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"🔑 Введите имя ключа:", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_key_name)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
|
||||
async def handle_key_name_input(
|
||||
message: types.Message, state: FSMContext, session: Any
|
||||
):
|
||||
key_name = sanitize_key_name(message.text)
|
||||
key_details = await get_key_details(key_name, session)
|
||||
|
||||
if not key_details:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"🚫 Пользователь с указанным именем ключа не найден.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
response_message = (
|
||||
f"🔑 Ключ: <code>{key_details['key']}</code>\n"
|
||||
f"⏰ Дата истечения: <b>{key_details['expiry_date']}</b>\n"
|
||||
f"💰 Баланс пользователя: <b>{key_details['balance']}</b>\n"
|
||||
f"🌐 Сервер: <b>{key_details['server_name']}</b>"
|
||||
)
|
||||
|
||||
key_buttons = InlineKeyboardBuilder()
|
||||
key_buttons.row(
|
||||
InlineKeyboardButton(
|
||||
text="ℹ️ Получить информацию о юзере",
|
||||
callback_data=f"user_info|{key_details['tg_id']}",
|
||||
)
|
||||
)
|
||||
key_buttons.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Изменить время истечения",
|
||||
callback_data=f"change_expiry|{key_name}",
|
||||
)
|
||||
)
|
||||
key_buttons.row(
|
||||
InlineKeyboardButton(
|
||||
text="❌ Удалить ключ",
|
||||
callback_data=f"delete_key_admin|{key_name}",
|
||||
)
|
||||
)
|
||||
key_buttons.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
|
||||
await message.answer(response_message, reply_markup=key_buttons.as_markup())
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("change_expiry|"), IsAdminFilter())
|
||||
async def prompt_expiry_change(callback_query: CallbackQuery, state: FSMContext):
|
||||
email = callback_query.data.split("|")[1]
|
||||
await callback_query.message.answer(
|
||||
f"⏳ Введите новое время истечения для ключа <b>{email}</b> в формате <code>YYYY-MM-DD HH:MM:SS</code>:"
|
||||
)
|
||||
await state.update_data(email=email)
|
||||
await state.set_state(UserEditorState.waiting_for_expiry_time)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter())
|
||||
async def handle_expiry_time_input(
|
||||
message: types.Message, state: FSMContext, session: Any
|
||||
):
|
||||
user_data = await state.get_data()
|
||||
email = user_data.get("email")
|
||||
|
||||
if not email:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"📧 Email не найден в состоянии. 🚫", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
expiry_time_str = message.text
|
||||
expiry_time = int(
|
||||
datetime.strptime(expiry_time_str, "%Y-%m-%d %H:%M:%S").timestamp() * 1000
|
||||
)
|
||||
|
||||
client_id = await get_client_id_by_email(email)
|
||||
if client_id is None:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
|
||||
)
|
||||
await message.answer(
|
||||
f"🚫 Клиент с email {email} не найден. 🔍",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
record = await session.fetchrow(
|
||||
"SELECT server_id FROM keys WHERE client_id = $1", client_id
|
||||
)
|
||||
if not record:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
|
||||
)
|
||||
await message.answer(
|
||||
"🚫 Клиент не найден в базе данных. 🔍",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
clusters = await get_servers_from_db()
|
||||
|
||||
async def update_key_on_all_servers():
|
||||
tasks = []
|
||||
for cluster_name, cluster_servers in clusters.items():
|
||||
for server in cluster_servers:
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
renew_key_in_cluster(
|
||||
cluster_name,
|
||||
email,
|
||||
client_id,
|
||||
expiry_time,
|
||||
total_gb=TOTAL_GB,
|
||||
)
|
||||
)
|
||||
)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
await update_key_on_all_servers()
|
||||
|
||||
await update_key_expiry(client_id, expiry_time)
|
||||
|
||||
response_message = f"✅ Время истечения ключа для клиента {client_id} ({email}) успешно обновлено на всех серверах."
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
|
||||
await message.answer(response_message, reply_markup=builder.as_markup())
|
||||
except ValueError:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"❌ Пожалуйста, используйте формат: YYYY-MM-DD HH:MM:SS.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_key_admin|"), IsAdminFilter())
|
||||
async def process_callback_delete_key(
|
||||
callback_query: types.CallbackQuery, session: Any
|
||||
):
|
||||
email = callback_query.data.split("|")[1]
|
||||
client_id = await session.fetchval(
|
||||
"SELECT client_id FROM keys WHERE email = $1", email
|
||||
)
|
||||
|
||||
if client_id is None:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"🔍 Ключ не найден. 🚫", reply_markup=builder.as_markup()
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=f"confirm_delete_admin|{client_id}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(text="❌ Нет, отменить", callback_data="user_editor")
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
"<b>❓ Вы уверены, что хотите удалить ключ?</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete_admin|"), IsAdminFilter())
|
||||
async def process_callback_confirm_delete(
|
||||
callback_query: types.CallbackQuery, session: Any
|
||||
):
|
||||
client_id = callback_query.data.split("|")[1]
|
||||
record = await session.fetchrow(
|
||||
"SELECT email FROM keys WHERE client_id = $1", client_id
|
||||
)
|
||||
|
||||
if record:
|
||||
email = record["email"]
|
||||
response_message = "✅ Ключ успешно удален."
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
|
||||
|
||||
clusters = await get_servers_from_db()
|
||||
|
||||
async def delete_key_from_servers(email, client_id):
|
||||
tasks = []
|
||||
for cluster_name, cluster_servers in clusters.items():
|
||||
for server in cluster_servers:
|
||||
tasks.append(
|
||||
delete_key_from_cluster(cluster_name, email, client_id)
|
||||
)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
await delete_key_from_servers(email, client_id)
|
||||
await delete_key_from_db(client_id, session)
|
||||
|
||||
await callback_query.message.answer(
|
||||
response_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
else:
|
||||
response_message = "🚫 Ключ не найден или уже удален."
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
|
||||
await callback_query.message.answer(
|
||||
response_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("user_info|"), IsAdminFilter())
|
||||
async def handle_user_info(
|
||||
callback_query: types.CallbackQuery, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = int(callback_query.data.split("|")[1])
|
||||
username = await session.fetchval(
|
||||
"SELECT username FROM users WHERE tg_id = $1", tg_id
|
||||
)
|
||||
balance = await session.fetchval(
|
||||
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
|
||||
)
|
||||
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
|
||||
referral_count = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for (email,) in key_records:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="📝 Изменить баланс", callback_data=f"change_balance_{tg_id}"))
|
||||
builder.row(InlineKeyboardButton(text="🔄 Восстановить пробник", callback_data=f"restore_trial_{tg_id}"))
|
||||
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
|
||||
user_info = (
|
||||
f"📊 Информация о пользователе:\n\n"
|
||||
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
|
||||
f"👤 Логин пользователя: <b>@{username}</b>\n"
|
||||
f"💰 Баланс: <b>{balance}</b>\n"
|
||||
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
|
||||
f"🔑 Ключи (для редактирования нажмите на ключ):"
|
||||
)
|
||||
|
||||
await callback_query.message.answer(user_info, reply_markup=builder.as_markup())
|
||||
await state.set_state(UserEditorState.displaying_user_info)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete_user_"), IsAdminFilter())
|
||||
async def confirm_delete_user(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = int(callback_query.data.split("_")[3])
|
||||
|
||||
confirmation_markup = InlineKeyboardMarkup(
|
||||
row_width=2,
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"delete_user_{tg_id}")],
|
||||
[InlineKeyboardButton(text="❌ Отменить", callback_data="user_editor")],
|
||||
],
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"Вы уверены, что хотите удалить пользователя с ID {tg_id}?", reply_markup=confirmation_markup
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_user_"), IsAdminFilter())
|
||||
async def delete_user(callback_query: types.CallbackQuery, session: Any):
|
||||
tg_id = int(callback_query.data.split("_")[2])
|
||||
|
||||
key_records = await session.fetch("SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id)
|
||||
|
||||
async def delete_keys_from_servers():
|
||||
try:
|
||||
tasks = []
|
||||
for email, client_id in key_records:
|
||||
servers = await get_servers_from_db()
|
||||
for cluster_id, cluster in servers.items():
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
await delete_keys_from_servers()
|
||||
|
||||
try:
|
||||
await delete_user_data(session, tg_id)
|
||||
|
||||
back_button = InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await callback_query.message.answer(f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=keyboard)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
|
||||
await callback_query.message.answer(
|
||||
f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .ads_handler import router
|
||||
@@ -0,0 +1,194 @@
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
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, 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>.\n\n💡 <b>Правила:</b> только латинские буквы и цифры",
|
||||
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"]
|
||||
|
||||
if not re.match(r"^[a-zA-Z0-9]+$", code):
|
||||
await message.answer(
|
||||
"❌ Код может содержать только латинские буквы и цифры\nВведите код заново:",
|
||||
reply_markup=build_cancel_input_kb(),
|
||||
)
|
||||
return
|
||||
|
||||
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, callback_data: AdminAdsCallback):
|
||||
try:
|
||||
result = await session.execute(select(TrackingSource).order_by(TrackingSource.created_at.desc()))
|
||||
ads = result.scalars().all()
|
||||
items_per_page = 6
|
||||
if callback_data.code and callback_data.code.isdigit():
|
||||
current_page = int(callback_data.code)
|
||||
else:
|
||||
current_page = 1
|
||||
total_pages = (len(ads) + items_per_page - 1) // items_per_page
|
||||
reply_markup = build_ads_list_kb(ads, current_page, total_pages)
|
||||
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:
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
now = datetime.now(moscow_tz)
|
||||
update_time = now.strftime("%d.%m.%y %H:%M:%S")
|
||||
|
||||
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> <code>{update_time}</code>"
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
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()
|
||||
items_per_page = 6
|
||||
|
||||
start = (current_page - 1) * items_per_page
|
||||
end = start + items_per_page
|
||||
page_ads = ads[start:end]
|
||||
|
||||
row = []
|
||||
for i, ad in enumerate(page_ads, 1):
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"📎 {ad['name']}",
|
||||
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
|
||||
)
|
||||
)
|
||||
if i % 2 == 0 or i == len(page_ads):
|
||||
builder.row(*row)
|
||||
row = []
|
||||
|
||||
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="view", code=code).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack(),
|
||||
)
|
||||
builder.row(build_admin_back_btn("ads"))
|
||||
builder.adjust(1)
|
||||
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,36 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from utils.backup import backup_database
|
||||
|
||||
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,201 @@
|
||||
import csv
|
||||
import io
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, Message
|
||||
from sqlalchemy import delete, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
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()
|
||||
|
||||
|
||||
class PreemptiveBanStates(StatesGroup):
|
||||
waiting_for_preemptive_ids = State()
|
||||
|
||||
|
||||
@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("❌ Ошибка при удалении вручную забаненных пользователей.")
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_preemptive"), IsAdminFilter())
|
||||
async def handle_preemptive_ban_start(callback: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(PreemptiveBanStates.waiting_for_preemptive_ids)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n"
|
||||
"Пример:\n<code>123456789\n987654321</code>",
|
||||
reply_markup=build_admin_back_kb("bans"),
|
||||
)
|
||||
|
||||
|
||||
@router.message(PreemptiveBanStates.waiting_for_preemptive_ids, IsAdminFilter())
|
||||
async def handle_preemptive_ids_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||
lines = message.text.strip().splitlines()
|
||||
tg_ids = set()
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.isdigit():
|
||||
tg_ids.add(int(line))
|
||||
|
||||
if not tg_ids:
|
||||
await message.answer("❌ Не найдено ни одного корректного Telegram ID.")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
stmt = (
|
||||
pg_insert(ManualBan)
|
||||
.values([
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"reason": "shadow",
|
||||
"banned_by": message.from_user.id,
|
||||
"until": None,
|
||||
"banned_at": now,
|
||||
}
|
||||
for tg_id in tg_ids
|
||||
])
|
||||
.on_conflict_do_update(
|
||||
index_elements=[ManualBan.tg_id],
|
||||
set_={
|
||||
"reason": "shadow",
|
||||
"until": None,
|
||||
"banned_by": message.from_user.id,
|
||||
"banned_at": now,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
await message.answer(f"✅ Успешно добавлено в теневой бан: <b>{len(tg_ids)}</b> пользователей.")
|
||||
await state.clear()
|
||||
@@ -0,0 +1,35 @@
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
|
||||
|
||||
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_preemptive").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,328 @@
|
||||
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="attach_tariff_menu", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_attach_tariff_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🗂 Спецгруппы",
|
||||
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🧹 Сбросить",
|
||||
callback_data=AdminClusterCallback(action="reset_cluster_groups", data=cluster_name).pack(),
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🗂 Подгруппы",
|
||||
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🧹 Сбросить",
|
||||
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_select_subgroup_servers_kb(
|
||||
cluster_name: str, cluster_servers: list, selected: set[str]
|
||||
) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
names = []
|
||||
for s in cluster_servers:
|
||||
if isinstance(s, str):
|
||||
names.append(s)
|
||||
elif isinstance(s, dict):
|
||||
names.append(s.get("server_name") or s.get("name") or str(s))
|
||||
else:
|
||||
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
|
||||
|
||||
for i, name in enumerate(names):
|
||||
mark = "✅" if name in selected else "⬜️"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{mark} {name}",
|
||||
callback_data=AdminClusterCallback(action="toggle_server_subgroup", data=f"{cluster_name}|{i}").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📚 Выбрать подгруппу",
|
||||
callback_data=AdminClusterCallback(action="choose_subgroup", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="♻️ Сбросить выбор",
|
||||
callback_data=AdminClusterCallback(action="reset_subgroup_selection", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_tariff_subgroup_selection_kb(cluster_name: str, subgroups: list[str]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for i, title in enumerate(subgroups):
|
||||
builder.button(
|
||||
text=title,
|
||||
callback_data=AdminClusterCallback(action="apply_tariff_subgroup", data=f"{cluster_name}|{i}").pack(),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад к выбору серверов",
|
||||
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.adjust(2, 1)
|
||||
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()
|
||||
|
||||
|
||||
def build_select_group_servers_kb(cluster_name: str, cluster_servers: list, selected: set[str]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
names = []
|
||||
for s in cluster_servers:
|
||||
if isinstance(s, str):
|
||||
names.append(s)
|
||||
elif isinstance(s, dict):
|
||||
names.append(s.get("server_name") or s.get("name") or str(s))
|
||||
else:
|
||||
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
|
||||
|
||||
for i, name in enumerate(names):
|
||||
mark = "✅" if name in selected else "⬜️"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{mark} {name}",
|
||||
callback_data=AdminClusterCallback(action="toggle_server_group", data=f"{cluster_name}|{i}").pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📚 Выбрать спецгруппу",
|
||||
callback_data=AdminClusterCallback(action="choose_group", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="♻️ Сбросить выбор",
|
||||
callback_data=AdminClusterCallback(action="reset_group_selection", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_tariff_group_selection_for_servers_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_group_to_servers", data=f"{cluster_name}|{group_id}"
|
||||
).pack(),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminClusterCallback(action="set_group", 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,326 @@
|
||||
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,84 @@
|
||||
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 .gifts_handler import router
|
||||
@@ -0,0 +1,273 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Gift, GiftUsage, Tariff
|
||||
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
|
||||
from handlers.utils import edit_or_send_message, format_days, format_months
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from .keyboard import build_admin_gifts_kb, build_gifts_list_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class GiftCreationState(StatesGroup):
|
||||
waiting_for_gift_limit = State()
|
||||
waiting_for_limit_input_or_unlimited = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "gifts"))
|
||||
async def admin_gift_menu(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
text="🎁 <b>Подарки</b>\nВыберите, что хотите сделать:", reply_markup=build_admin_gifts_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_gift_create")
|
||||
async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession):
|
||||
tariffs_data = await get_tariffs(session, group_code="gifts", with_subgroup_weights=True)
|
||||
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
|
||||
subgroup_weights = tariffs_data["subgroup_weights"]
|
||||
|
||||
if not tariffs:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
||||
await callback.message.edit_text("❌ Нет активных тарифов в группе 'gifts'.", reply_markup=builder.as_markup())
|
||||
return
|
||||
|
||||
grouped_tariffs = defaultdict(list)
|
||||
for t in tariffs:
|
||||
grouped_tariffs[t.get("subgroup_title")].append(t)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for t in grouped_tariffs.get(None, []):
|
||||
if t.get("duration_days") % 30 == 0:
|
||||
duration_text = format_months(t.get("duration_days") // 30)
|
||||
else:
|
||||
duration_text = format_days(t.get("duration_days"))
|
||||
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{t.get('name')} – {duration_text}", callback_data=f"admin_gift_select|{t.get('id')}"
|
||||
)
|
||||
)
|
||||
|
||||
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
|
||||
|
||||
for subgroup in sorted_subgroups:
|
||||
subgroup_hash = create_subgroup_hash(subgroup, "gifts")
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=subgroup,
|
||||
callback_data=f"admin_gift_subgroup|{subgroup_hash}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
|
||||
|
||||
await callback.message.edit_text("🎁 Выберите тариф для подарка:", reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_gift_subgroup|"))
|
||||
async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session: AsyncSession):
|
||||
try:
|
||||
subgroup_hash = callback.data.split("|", 1)[1]
|
||||
|
||||
subgroup = await find_subgroup_by_hash(session, subgroup_hash, "gifts")
|
||||
if not subgroup:
|
||||
await callback.message.edit_text("❌ Подгруппа не найдена.")
|
||||
return
|
||||
|
||||
tariffs = await get_tariffs(session, group_code="gifts")
|
||||
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")]
|
||||
if not filtered:
|
||||
await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for t in filtered:
|
||||
if t.get("duration_days") % 30 == 0:
|
||||
duration_text = format_months(t.get("duration_days") // 30)
|
||||
else:
|
||||
duration_text = format_days(t.get("duration_days"))
|
||||
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{t.get('name')} – {duration_text}",
|
||||
callback_data=f"admin_gift_select|{t.get('id')}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_gift_create"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text=f"<b>{subgroup}</b>\n\nВыберите тариф:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ADMIN_GIFT_SUBGROUP] Ошибка при отображении подгруппы: {e}")
|
||||
await callback.message.answer("❌ Произошла ошибка при отображении тарифов.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_gift_select|"))
|
||||
async def handle_tariff_selection(callback: CallbackQuery, state: FSMContext):
|
||||
tariff_id = int(callback.data.split("|")[1])
|
||||
await state.update_data(tariff_id=tariff_id)
|
||||
await state.set_state(GiftCreationState.waiting_for_limit_input_or_unlimited)
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="🔙 Назад", callback_data="admin_gift_create")
|
||||
await callback.message.edit_text(
|
||||
"🔢 Введите максимальное количество активаций подарка:", reply_markup=kb.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "gift_limit_unlimited")
|
||||
async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot: Bot):
|
||||
from handlers.payments.gift import finalize_gift
|
||||
|
||||
data = await state.get_data()
|
||||
session: AsyncSession = callback.bot["session"]
|
||||
await state.clear()
|
||||
await finalize_gift(callback.message, session, bot, data, is_unlimited=True)
|
||||
|
||||
|
||||
@router.message(GiftCreationState.waiting_for_limit_input_or_unlimited)
|
||||
async def handle_limited_gift_input(message: types.Message, session: AsyncSession, state: FSMContext, bot: Bot):
|
||||
from handlers.payments.gift import finalize_gift
|
||||
|
||||
try:
|
||||
max_usages = int(message.text.strip())
|
||||
if max_usages <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное положительное число.")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
data["max_usages"] = max_usages
|
||||
await state.clear()
|
||||
await finalize_gift(message, session, bot, data, is_unlimited=False)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_gifts_all")
|
||||
async def show_gifts_page(callback: CallbackQuery, session: AsyncSession):
|
||||
await show_gift_list(callback, session, page=1)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("gifts_page|"))
|
||||
async def paginate_gifts(callback: CallbackQuery, session: AsyncSession):
|
||||
page = int(callback.data.split("|")[1])
|
||||
await show_gift_list(callback, session, page)
|
||||
|
||||
|
||||
async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: int):
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
stmt = select(Gift).order_by(Gift.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
gifts = result.scalars().all()
|
||||
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if not gifts:
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
||||
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
|
||||
return
|
||||
|
||||
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
|
||||
|
||||
builder.inline_keyboard.extend(keyboard.inline_keyboard)
|
||||
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
|
||||
|
||||
await callback.message.edit_text(f"🎁 <b>Список подарков</b>\nСтраница {page}:", reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: int):
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
stmt = select(Gift).order_by(Gift.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
gifts = result.scalars().all()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if not gifts:
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
||||
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
|
||||
return
|
||||
|
||||
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
|
||||
|
||||
for row in keyboard.inline_keyboard:
|
||||
builder.row(*row)
|
||||
|
||||
await callback.message.edit_text(f"🎁 <b>Список подарков</b>\nСтраница {page}:", reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("gift_view|"))
|
||||
async def view_gift(callback: CallbackQuery, session: AsyncSession):
|
||||
gift_id = callback.data.split("|")[1]
|
||||
|
||||
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
|
||||
gift = result.scalar_one_or_none()
|
||||
|
||||
if not gift:
|
||||
await callback.message.edit_text("❌ Подарок не найден.")
|
||||
return
|
||||
|
||||
usage_result = await session.execute(
|
||||
select(func.count()).select_from(GiftUsage).where(GiftUsage.gift_id == gift_id)
|
||||
)
|
||||
used_count = usage_result.scalar_one()
|
||||
usage_text = f"{used_count}/{gift.max_usages}" if gift.max_usages else "∞"
|
||||
|
||||
duration_days = (gift.expiry_time.date() - gift.created_at.date()).days
|
||||
if duration_days % 30 == 0:
|
||||
duration_text = format_months(duration_days // 30)
|
||||
else:
|
||||
duration_text = format_days(duration_days)
|
||||
|
||||
text = (
|
||||
f"🎁 <b>Подарок</b>\n"
|
||||
f"ID: <code>{gift.gift_id}</code>\n"
|
||||
f"Срок: <b>{duration_text}</b>\n"
|
||||
f"Активаций: <b>{usage_text}</b>\n"
|
||||
f"<b>Ссылка для активации:</b>\n<blockquote>{gift.gift_link}</blockquote>"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🗑 Удалить", callback_data=f"gift_delete|{gift_id}")
|
||||
builder.button(text="🔙 Назад", callback_data="admin_gifts_all")
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=builder.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("gift_delete|"))
|
||||
async def delete_gift(callback: CallbackQuery, session: AsyncSession):
|
||||
gift_id = callback.data.split("|")[1]
|
||||
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.execute(delete(Gift).where(Gift.gift_id == gift_id))
|
||||
await session.commit()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔙 Назад к списку", callback_data="admin_gifts_all")
|
||||
|
||||
await callback.message.edit_text("✅ Подарок удалён.", reply_markup=builder.as_markup())
|
||||
@@ -0,0 +1,69 @@
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from database.models import Gift, Tariff
|
||||
from handlers.buttons import BACK
|
||||
from handlers.utils import format_days, format_months
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
|
||||
|
||||
def build_admin_gifts_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🎁 Создать подарок", callback_data="admin_gift_create"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📦 Все подарки", callback_data="admin_gifts_all"),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="admin"))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_gift_tariffs_kb(tariffs: list[Tariff]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for tariff in tariffs:
|
||||
builder.button(
|
||||
text=f"{tariff.name} — {tariff.duration_days // 30} мес.",
|
||||
callback_data=f"admin_gift_confirm|{tariff.id}",
|
||||
)
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
row = []
|
||||
|
||||
for i, gift in enumerate(gifts):
|
||||
if gift.selected_months > 0:
|
||||
duration_text = format_months(gift.selected_months)
|
||||
else:
|
||||
days = (gift.expiry_time.date() - gift.created_at.date()).days
|
||||
duration_text = format_days(days)
|
||||
|
||||
button_text = f"{gift.gift_id[:6]}... — {duration_text}"
|
||||
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"gift_view|{gift.gift_id}",
|
||||
)
|
||||
)
|
||||
|
||||
if len(row) == 2 or i == len(gifts) - 1:
|
||||
builder.row(*row)
|
||||
row = []
|
||||
|
||||
nav = []
|
||||
if page > 1:
|
||||
nav.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"gifts_page|{page - 1}"))
|
||||
if len(gifts) == 10:
|
||||
nav.append(InlineKeyboardButton(text="➡️ Далее", callback_data=f"gifts_page|{page + 1}"))
|
||||
if nav:
|
||||
builder.row(*nav)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
|
||||
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .management_handler import router
|
||||
@@ -0,0 +1,161 @@
|
||||
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(admin_role: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if admin_role == "superadmin":
|
||||
builder.button(
|
||||
text="👑 Управление админами",
|
||||
callback_data=AdminPanelCallback(action="admins").pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="🗄 Управление БД",
|
||||
callback_data=AdminPanelCallback(action="database").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(),
|
||||
)
|
||||
builder.button(
|
||||
text="📤 Загрузить файл",
|
||||
callback_data=AdminPanelCallback(action="upload_file").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()
|
||||
|
||||
|
||||
def build_database_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="💾 Создать резервную копию",
|
||||
callback_data=AdminPanelCallback(action="backups").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="♻️ Восстановить БД из бэкапа",
|
||||
callback_data=AdminPanelCallback(action="restore_db").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📤 Получить данные БД из панели",
|
||||
callback_data=AdminPanelCallback(action="export_db").pack(),
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_back_to_db_menu() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="⬅️ Назад", callback_data=AdminPanelCallback(action="database").pack())
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_export_db_sources_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text="🌀 Remnawave", callback_data=AdminPanelCallback(action="export_remnawave").pack())
|
||||
builder.button(text="🧩 3x-ui", callback_data=AdminPanelCallback(action="request_3xui_file").pack())
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_admins_kb(admins: list[tuple[int, str]]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for tg_id, role in admins:
|
||||
builder.button(
|
||||
text=f"🧑 {tg_id} ({role})", callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack()
|
||||
)
|
||||
|
||||
builder.button(text="➕ Добавить админа", callback_data=AdminPanelCallback(action="add_admin").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text="✏ Изменить роль", callback_data=AdminPanelCallback(action=f"edit_role|{tg_id}").pack())
|
||||
|
||||
builder.button(text="🗑 Удалить админа", callback_data=AdminPanelCallback(action=f"delete_admin|{tg_id}").pack())
|
||||
|
||||
if role == "superadmin":
|
||||
builder.button(
|
||||
text="🎟 Выпустить токен", callback_data=AdminPanelCallback(action=f"generate_token|{tg_id}").pack()
|
||||
)
|
||||
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_role_selection_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="👑 superadmin", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|superadmin").pack())
|
||||
builder.button(text="🛡 moderator", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|moderator").pack())
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_admin_back_kb_to_admins() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_token_result_kb(token: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="📋 Скопировать токен", switch_inline_query_current_chat=token)
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_back_to_db_menu() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_post_import_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="🔁 Перевыпустить подписки", callback_data=AdminPanelCallback(action="resync_after_import").pack()
|
||||
)
|
||||
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,687 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from asyncio import sleep
|
||||
from datetime import datetime
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from aiogram import Bot, F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from dateutil import parser
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
|
||||
from database.models import Admin, Key, Server, User
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.operations import update_subscription
|
||||
from logger import logger
|
||||
from middlewares import maintenance
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminPanelCallback,
|
||||
build_admin_back_kb_to_admins,
|
||||
build_admins_kb,
|
||||
build_back_to_db_menu,
|
||||
build_database_kb,
|
||||
build_export_db_sources_kb,
|
||||
build_management_kb,
|
||||
build_post_import_kb,
|
||||
build_role_selection_kb,
|
||||
build_single_admin_menu,
|
||||
build_token_result_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminManagementStates(StatesGroup):
|
||||
waiting_for_new_domain = State()
|
||||
|
||||
|
||||
class Import3xuiStates(StatesGroup):
|
||||
waiting_for_file = State()
|
||||
|
||||
|
||||
class FileUploadState(StatesGroup):
|
||||
waiting_for_file = State()
|
||||
|
||||
|
||||
class DatabaseState(StatesGroup):
|
||||
waiting_for_backup_file = State()
|
||||
|
||||
|
||||
class AdminState(StatesGroup):
|
||||
waiting_for_tg_id = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
|
||||
async def handle_management(callback_query: CallbackQuery, session: AsyncSession):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
await callback_query.message.edit_text("❌ Вы не зарегистрированы как администратор.")
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="🤖 Управление ботом",
|
||||
reply_markup=build_management_kb(admin.role),
|
||||
)
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
if not re.fullmatch(r"[a-zA-Z0-9.-]+", new_domain) or " " in new_domain:
|
||||
logger.warning("[DomainChange] Некорректный домен")
|
||||
await message.answer(
|
||||
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
return
|
||||
|
||||
new_domain_url = f"https://{new_domain}"
|
||||
|
||||
try:
|
||||
stmt = (
|
||||
update(Key)
|
||||
.values(
|
||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||
)
|
||||
.where(
|
||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(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, Key.remnawave_link).limit(1))
|
||||
example = sample.fetchone()
|
||||
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, session: AsyncSession):
|
||||
tg_id = callback.from_user.id
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
if not admin:
|
||||
await callback.answer("❌ Админ не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
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(admin.role))
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "admins"))
|
||||
async def show_admins(callback: CallbackQuery, session: AsyncSession):
|
||||
result = await session.execute(select(Admin.tg_id, Admin.role))
|
||||
admins = result.all()
|
||||
await callback.message.edit_text("👑 <b>Список админов</b>", reply_markup=build_admins_kb(admins))
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "add_admin"))
|
||||
async def prompt_new_admin(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"Введите <code>tg_id</code> нового админа:", reply_markup=build_admin_back_kb_to_admins()
|
||||
)
|
||||
await state.set_state(AdminState.waiting_for_tg_id)
|
||||
|
||||
|
||||
@router.message(AdminState.waiting_for_tg_id)
|
||||
async def save_new_admin(message: Message, session: AsyncSession, state: FSMContext):
|
||||
try:
|
||||
tg_id = int(message.text.strip())
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат. Введите числовой <code>tg_id</code>.")
|
||||
return
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
if result.scalar_one_or_none():
|
||||
await message.answer("⚠️ Такой админ уже существует.")
|
||||
else:
|
||||
session.add(Admin(tg_id=tg_id, role="moderator", description="Добавлен вручную"))
|
||||
await session.commit()
|
||||
await message.answer(f"✅ Админ <code>{tg_id}</code> добавлен.", reply_markup=build_admin_back_kb_to_admins())
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("admin_menu|")))
|
||||
async def open_admin_menu(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Admin.role).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
role = admin or "moderator"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👤 <b>Управление админом</b> <code>{tg_id}</code>", reply_markup=build_single_admin_menu(tg_id, role)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("generate_token|")))
|
||||
async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
if not admin:
|
||||
await callback.message.edit_text("❌ Админ не найден.")
|
||||
return
|
||||
|
||||
token = Admin.generate_token()
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
admin.token = token_hash
|
||||
await session.commit()
|
||||
|
||||
msg = await callback.message.edit_text(
|
||||
f"🎟 <b>Новый токен для</b> <code>{tg_id}</code>:\n\n"
|
||||
f"<code>{token}</code>\n\n"
|
||||
f"⚠️ Это сообщение исчезнет через 5 минут.",
|
||||
reply_markup=build_token_result_kb(token),
|
||||
)
|
||||
|
||||
await sleep(300)
|
||||
try:
|
||||
await msg.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("edit_role|")))
|
||||
async def edit_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
await callback.message.edit_text(
|
||||
f"✏ <b>Выберите новую роль для</b> <code>{tg_id}</code>:", reply_markup=build_role_selection_kb(tg_id)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("set_role|")))
|
||||
async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
try:
|
||||
_, tg_id_str, role = callback_data.action.split("|")
|
||||
tg_id = int(tg_id_str)
|
||||
if role not in ("superadmin", "moderator"):
|
||||
raise ValueError
|
||||
except Exception:
|
||||
await callback.message.edit_text("❌ Неверный формат.")
|
||||
return
|
||||
|
||||
if tg_id == callback.from_user.id:
|
||||
await callback.message.edit_text(
|
||||
"🚫 <b>Нельзя изменить свою собственную роль!</b>", reply_markup=build_single_admin_menu(tg_id)
|
||||
)
|
||||
return
|
||||
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
|
||||
admin = result.scalar_one_or_none()
|
||||
if not admin:
|
||||
await callback.message.edit_text("❌ Админ не найден.")
|
||||
return
|
||||
|
||||
admin.role = role
|
||||
await session.commit()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.", reply_markup=build_single_admin_menu(tg_id)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("delete_admin|")))
|
||||
async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
|
||||
tg_id = int(callback_data.action.split("|")[1])
|
||||
|
||||
await session.execute(delete(Admin).where(Admin.tg_id == tg_id))
|
||||
await session.commit()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "database"))
|
||||
async def handle_database_menu(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
text="🗄 <b>Управление базой данных</b>",
|
||||
reply_markup=build_database_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_db"))
|
||||
async def prompt_restore_db(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"📂 Отправьте файл резервной копии (.sql), чтобы восстановить базу данных.\n"
|
||||
"⚠️ Все текущие данные будут перезаписаны.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
await state.set_state(DatabaseState.waiting_for_backup_file)
|
||||
|
||||
|
||||
@router.message(DatabaseState.waiting_for_backup_file)
|
||||
async def restore_database(message: Message, state: FSMContext, bot: Bot):
|
||||
document = message.document
|
||||
|
||||
if not document or not document.file_name.endswith(".sql"):
|
||||
await message.answer("❌ Пожалуйста, отправьте файл с расширением .sql.")
|
||||
return
|
||||
|
||||
try:
|
||||
with NamedTemporaryFile(delete=False, suffix=".sql") as tmp_file:
|
||||
tmp_path = tmp_file.name
|
||||
|
||||
await bot.download(document, destination=tmp_path)
|
||||
logger.info(f"[Restore] Файл получен и сохранён: {tmp_path}")
|
||||
|
||||
is_custom_dump = False
|
||||
with open(tmp_path, "rb") as f:
|
||||
signature = f.read(5)
|
||||
if signature == b"PGDMP":
|
||||
is_custom_dump = True
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"-u",
|
||||
"postgres",
|
||||
"psql",
|
||||
"-d",
|
||||
"postgres",
|
||||
"-c",
|
||||
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{DB_NAME}' AND pid <> pg_backend_pid();",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"DROP DATABASE IF EXISTS {DB_NAME};"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"CREATE DATABASE {DB_NAME} OWNER {DB_USER};"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
logger.info("[Restore] База данных пересоздана")
|
||||
|
||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||
|
||||
if is_custom_dump:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pg_restore",
|
||||
f"--dbname={DB_NAME}",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
PG_HOST,
|
||||
"-p",
|
||||
PG_PORT,
|
||||
"--no-owner",
|
||||
"--exit-on-error",
|
||||
tmp_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
PG_HOST,
|
||||
"-p",
|
||||
PG_PORT,
|
||||
"-d",
|
||||
DB_NAME,
|
||||
"-f",
|
||||
tmp_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
del os.environ["PGPASSWORD"]
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"[Restore] Ошибка восстановления: {result.stderr}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка при восстановлении базы данных:\n<pre>{result.stderr}</pre>",
|
||||
)
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
"✅ База данных восстановлена.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
logger.info("[Restore] Успешно восстановлено. Завершаем процесс для перезапуска.")
|
||||
await state.clear()
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"[Restore] Непредвиденная ошибка: {e}")
|
||||
await message.answer(
|
||||
f"❌ Произошла ошибка:\n<pre>{traceback.format_exc()}</pre>",
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "export_db"))
|
||||
async def handle_export_db(callback: CallbackQuery):
|
||||
await callback.message.edit_text(
|
||||
"📤 Выберите панель, с которой требуется получить данные:\n\n"
|
||||
"<i>Подтянутся подписки с панели и будут сохранены в базу данных бота.</i>",
|
||||
reply_markup=build_export_db_sources_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "back_to_db_menu"))
|
||||
async def back_to_database_menu(callback: CallbackQuery):
|
||||
await callback.message.edit_text("📦 Управление базой данных:", reply_markup=build_database_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"))
|
||||
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
|
||||
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
|
||||
servers = result.scalars().all()
|
||||
|
||||
if not servers:
|
||||
await callback.message.edit_text(
|
||||
"❌ Нет доступных Remnawave-серверов.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
return
|
||||
|
||||
server = servers[0]
|
||||
|
||||
api = RemnawaveAPI(base_url=server.api_url)
|
||||
|
||||
users = await api.get_all_users_time(
|
||||
username=REMNAWAVE_LOGIN,
|
||||
password=REMNAWAVE_PASSWORD,
|
||||
)
|
||||
|
||||
if not users:
|
||||
await callback.message.edit_text(
|
||||
"📭 На панели нет клиентов.",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
return
|
||||
|
||||
logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}")
|
||||
|
||||
added_users = await import_remnawave_users(session, users)
|
||||
|
||||
server_id = server.cluster_name or server.server_name
|
||||
|
||||
added_keys = await import_remnawave_keys(session, users, server_id=server_id)
|
||||
|
||||
preview = ""
|
||||
for i, user in enumerate(users[:3], 1):
|
||||
email = user.get("email") or user.get("username") or "-"
|
||||
expire = user.get("expireAt", "")[:10]
|
||||
preview += f"{i}. {email} — до {expire}\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📄 Найдено клиентов: <b>{len(users)}</b>\n"
|
||||
f"👤 Импортировано пользователей: <b>{added_users}</b>\n"
|
||||
f"🔐 Импортировано ключей: <b>{added_keys}</b>\n\n"
|
||||
f"<b>Первые 3:</b>\n{preview}",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
|
||||
|
||||
async def import_remnawave_users(session: AsyncSession, users: list[dict]) -> int:
|
||||
added = 0
|
||||
|
||||
for user in users:
|
||||
tg_id = user.get("telegramId")
|
||||
if not tg_id:
|
||||
continue
|
||||
|
||||
exists = await session.execute(select(User).where(User.tg_id == tg_id))
|
||||
if exists.scalar():
|
||||
continue
|
||||
|
||||
try:
|
||||
new_user = User(
|
||||
tg_id=tg_id,
|
||||
username=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
language_code=None,
|
||||
is_bot=False,
|
||||
balance=0.0,
|
||||
trial=1,
|
||||
source_code=None,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
session.add(new_user)
|
||||
added += 1
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[Remnawave Import] Ошибка при добавлении пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
await session.commit()
|
||||
return added
|
||||
|
||||
|
||||
async def import_remnawave_keys(session: AsyncSession, users: list[dict], server_id: str) -> int:
|
||||
added = 0
|
||||
|
||||
for user in users:
|
||||
tg_id = user.get("telegramId")
|
||||
client_id = user.get("uuid")
|
||||
email = user.get("email") or user.get("username")
|
||||
remnawave_link = user.get("subscriptionUrl")
|
||||
expire_at = user.get("expireAt")
|
||||
created_at = user.get("createdAt")
|
||||
|
||||
if not tg_id or not client_id:
|
||||
logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}")
|
||||
continue
|
||||
|
||||
exists_stmt = await session.execute(select(Key).where(Key.client_id == client_id))
|
||||
if exists_stmt.scalar():
|
||||
logger.info(f"[SKIP] Ключ уже существует: {client_id}")
|
||||
continue
|
||||
|
||||
try:
|
||||
created_ts = int(parser.isoparse(created_at).timestamp() * 1000) if created_at else int(time.time() * 1000)
|
||||
expire_ts = int(parser.isoparse(expire_at).timestamp() * 1000) if expire_at else int(time.time() * 1000)
|
||||
|
||||
new_key = Key(
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
created_at=created_ts,
|
||||
expiry_time=expire_ts,
|
||||
key="",
|
||||
server_id=server_id,
|
||||
remnawave_link=remnawave_link,
|
||||
tariff_id=None,
|
||||
is_frozen=False,
|
||||
alias=None,
|
||||
notified=False,
|
||||
notified_24h=False,
|
||||
)
|
||||
session.add(new_key)
|
||||
added += 1
|
||||
|
||||
logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}")
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
|
||||
return added
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "request_3xui_file"))
|
||||
async def prompt_for_3xui_file(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"📂 Пришлите файл базы данных <code>x-ui.db</code> для восстановления подписок и клиентов.\n\n"
|
||||
"Формат: SQLite-файл с таблицей <code>inbounds</code>.\n\n"
|
||||
"<b>⚠️ Важно!</b> Убедитесь, что у всех подписок в панели прописан <code>telegram_id</code>.\n"
|
||||
"После восстановления обязательно выполните <b>синхронизацию</b> с текущими серверами!",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
await state.set_state(Import3xuiStates.waiting_for_file)
|
||||
|
||||
|
||||
@router.message(Import3xuiStates.waiting_for_file, F.document)
|
||||
async def handle_3xui_db_upload(message: Message, state: FSMContext, session: AsyncSession):
|
||||
file = message.document
|
||||
|
||||
if not file.file_name.endswith(".db"):
|
||||
await message.reply("❌ Пожалуйста, пришли файл с расширением .db")
|
||||
return
|
||||
|
||||
file_path = f"/tmp/{file.file_name}"
|
||||
await message.bot.download(file, destination=file_path)
|
||||
|
||||
processing_message = await message.reply("📥 Файл получен. Начинаю восстановление...")
|
||||
|
||||
try:
|
||||
from database.importer import import_keys_from_3xui_db
|
||||
|
||||
imported, skipped = await import_keys_from_3xui_db(file_path, session)
|
||||
|
||||
await processing_message.edit_text(
|
||||
f"✅ Восстановление завершено:\n"
|
||||
f"🔐 Импортировано подписок: <b>{imported}</b>\n"
|
||||
f"⏭ Пропущено (уже есть): <b>{skipped}</b>",
|
||||
reply_markup=build_post_import_kb(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Import 3x-ui] Ошибка: {e}")
|
||||
await processing_message.edit_text(
|
||||
"❌ Произошла ошибка при импорте. Убедись, что это валидный файл <code>x-ui.db</code>",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "resync_after_import"))
|
||||
async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSession):
|
||||
await callback.answer("🔁 Начинаю перевыпуск подписок...")
|
||||
|
||||
result = await session.execute(select(Key.tg_id, Key.email))
|
||||
keys = result.all()
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for tg_id, email in keys:
|
||||
try:
|
||||
await update_subscription(tg_id=tg_id, email=email, session=session)
|
||||
success += 1
|
||||
except Exception as e:
|
||||
logger.error(f"[Resync] Ошибка при перевыпуске {email}: {e}")
|
||||
failed += 1
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
|
||||
reply_markup=build_back_to_db_menu(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"))
|
||||
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"📤 <b>Загрузка файла</b>\n\n"
|
||||
"Вы можете заменить файл в корневой директории бота.\n\n"
|
||||
"📁 <b>Отправьте файл с таким же именем и расширением</b>, "
|
||||
"как у уже существующего файла. Он будет автоматически заменён.",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
await state.set_state(FileUploadState.waiting_for_file)
|
||||
|
||||
|
||||
@router.message(FileUploadState.waiting_for_file, F.document)
|
||||
async def handle_admin_file_upload(message: Message, state: FSMContext):
|
||||
document = message.document
|
||||
file_name = document.file_name
|
||||
|
||||
if not file_name or "." not in file_name:
|
||||
await message.answer("❌ У файла должно быть имя с расширением.")
|
||||
return
|
||||
|
||||
dest_path = os.path.abspath(f"./{file_name}")
|
||||
|
||||
try:
|
||||
await message.bot.download(document, destination=dest_path)
|
||||
await message.answer(
|
||||
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён.\n\n"
|
||||
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
|
||||
await message.answer(
|
||||
f"❌ Не удалось сохранить файл: {e}",
|
||||
reply_markup=build_admin_back_kb("management"),
|
||||
)
|
||||
await state.clear()
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .module_handler import router
|
||||
@@ -0,0 +1,93 @@
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.admin.panel.keyboard import AdminPanelCallback
|
||||
from handlers.buttons import BACK
|
||||
from utils.modules_manager import manager
|
||||
|
||||
|
||||
def build_modules_kb(page: int, total_pages: int, items: list[tuple[str, str | None]]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
row_buf = []
|
||||
for name, _ in items:
|
||||
label = name if manager.is_enabled(name) else f"{name} (off)"
|
||||
row_buf.append(
|
||||
InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=AdminPanelCallback(action=f"module__{name}", page=page).pack(),
|
||||
)
|
||||
)
|
||||
if len(row_buf) == 2:
|
||||
builder.row(*row_buf)
|
||||
row_buf = []
|
||||
if row_buf:
|
||||
builder.row(*row_buf)
|
||||
|
||||
if total_pages > 1:
|
||||
nav = []
|
||||
if page > 1:
|
||||
nav.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminPanelCallback(action="modules", page=page - 1).pack(),
|
||||
)
|
||||
)
|
||||
nav.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}",
|
||||
callback_data=AdminPanelCallback(action="modules", page=page).pack(),
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
nav.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=AdminPanelCallback(action="modules", page=page + 1).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(*nav)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="admin", page=1).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_module_menu_kb(name: str, page: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
enabled = manager.is_enabled(name)
|
||||
|
||||
if enabled:
|
||||
builder.button(
|
||||
text="🔁 Перезапустить",
|
||||
callback_data=AdminPanelCallback(action=f"module_restart__{name}", page=page).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🛑 Остановить",
|
||||
callback_data=AdminPanelCallback(action=f"module_stop__{name}", page=page).pack(),
|
||||
)
|
||||
else:
|
||||
builder.button(
|
||||
text="▶️ Запустить",
|
||||
callback_data=AdminPanelCallback(action=f"module_start__{name}", page=page).pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="🔄 Обновить",
|
||||
callback_data=AdminPanelCallback(action=f"module_update__{name}", page=page).pack(),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ К списку",
|
||||
callback_data=AdminPanelCallback(action="modules", page=page).pack(),
|
||||
)
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,171 @@
|
||||
import os
|
||||
|
||||
from math import ceil
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.admin.panel.keyboard import AdminPanelCallback
|
||||
from utils.modules_manager import manager
|
||||
|
||||
from .keyboard import build_module_menu_kb, build_modules_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def list_installed_modules() -> list[tuple[str, str | None]]:
|
||||
base = "modules"
|
||||
if not os.path.isdir(base):
|
||||
return []
|
||||
items: list[tuple[str, str | None]] = []
|
||||
for name in sorted(os.listdir(base)):
|
||||
path = os.path.join(base, name)
|
||||
if os.path.isdir(path) and not name.startswith("."):
|
||||
ver = None
|
||||
vp = os.path.join(path, "VERSION")
|
||||
if os.path.isfile(vp):
|
||||
try:
|
||||
with open(vp, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
ver = line
|
||||
break
|
||||
except Exception:
|
||||
ver = None
|
||||
items.append((name, ver))
|
||||
return items
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "modules"), IsAdminFilter())
|
||||
async def handle_modules(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await state.clear()
|
||||
|
||||
packed = AdminPanelCallback.unpack(callback_query.data)
|
||||
page = max(1, packed.page or 1)
|
||||
|
||||
all_items = list_installed_modules()
|
||||
items = [(n, v) for n, v in all_items if n != "web_admin_panel"]
|
||||
|
||||
per_page = 12
|
||||
total_pages = max(1, (len(items) + per_page - 1) // per_page)
|
||||
page = min(page, total_pages)
|
||||
start = (page - 1) * per_page
|
||||
chunk = items[start : start + per_page]
|
||||
|
||||
if chunk:
|
||||
|
||||
def fmt(n, v):
|
||||
return f"{n} v{v}" if v else n
|
||||
|
||||
lines = "\n".join(f"• {fmt(n, v)}" for n, v in chunk)
|
||||
text = f"🧩 Мои модули\n\nНайдено: {len(items)}\n<blockquote>{lines}</blockquote>"
|
||||
else:
|
||||
text = "🧩 Мои модули\n\nМодулей не найдено."
|
||||
|
||||
markup = build_modules_kb(page, total_pages, chunk)
|
||||
try:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e).lower():
|
||||
try:
|
||||
await callback_query.message.edit_reply_markup(reply_markup=None)
|
||||
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_restart__")), IsAdminFilter())
|
||||
async def handle_module_restart(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await state.clear()
|
||||
|
||||
packed = AdminPanelCallback.unpack(callback_query.data)
|
||||
action = packed.action
|
||||
page = packed.page or 1
|
||||
name = action.split("module_restart__", 1)[-1]
|
||||
|
||||
try:
|
||||
await manager.restart(name)
|
||||
result = "✅ Модуль перезапущен."
|
||||
except Exception as e:
|
||||
result = f"❌ Ошибка перезапуска: {e}"
|
||||
|
||||
items = dict(list_installed_modules())
|
||||
ver = items.get(name)
|
||||
title = f"{name} v{ver}" if ver else name
|
||||
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
|
||||
|
||||
markup = build_module_menu_kb(name, page)
|
||||
try:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e).lower():
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_stop__")), IsAdminFilter())
|
||||
async def handle_module_stop(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await state.clear()
|
||||
|
||||
packed = AdminPanelCallback.unpack(callback_query.data)
|
||||
action = packed.action
|
||||
page = packed.page or 1
|
||||
name = action.split("module_stop__", 1)[-1]
|
||||
|
||||
try:
|
||||
await manager.stop(name)
|
||||
result = "🛑 Модуль остановлен."
|
||||
except Exception as e:
|
||||
result = f"❌ Ошибка остановки: {e}"
|
||||
|
||||
items = dict(list_installed_modules())
|
||||
ver = items.get(name)
|
||||
title = f"{name} v{ver}" if ver else name
|
||||
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
|
||||
|
||||
markup = build_module_menu_kb(name, page)
|
||||
try:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e).lower():
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_start__")), IsAdminFilter())
|
||||
async def handle_module_start(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await state.clear()
|
||||
|
||||
packed = AdminPanelCallback.unpack(callback_query.data)
|
||||
action = packed.action
|
||||
page = packed.page or 1
|
||||
name = action.split("module_start__", 1)[-1]
|
||||
|
||||
try:
|
||||
await manager.start(name)
|
||||
result = "▶️ Модуль запущен."
|
||||
except Exception as e:
|
||||
result = f"❌ Ошибка запуска: {e}"
|
||||
|
||||
items = dict(list_installed_modules())
|
||||
ver = items.get(name)
|
||||
title = f"{name} v{ver}" if ver else name
|
||||
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
|
||||
|
||||
markup = build_module_menu_kb(name, page)
|
||||
try:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e).lower():
|
||||
return
|
||||
raise
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .panel_handler import router
|
||||
@@ -0,0 +1,108 @@
|
||||
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
|
||||
from hooks.hook_buttons import insert_hook_buttons
|
||||
from hooks.hooks import run_hooks
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="👤 Поиск пользователя", callback_data=AdminPanelCallback(action="search_user").pack()
|
||||
),
|
||||
InlineKeyboardButton(text="🔑 Поиск подписок", callback_data=AdminPanelCallback(action="search_key").pack()),
|
||||
)
|
||||
|
||||
if admin_role == "superadmin":
|
||||
builder.button(
|
||||
text="🖥️ Управление серверами",
|
||||
callback_data=AdminPanelCallback(action="clusters").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="💸Управление тарифами",
|
||||
callback_data=AdminPanelCallback(action="tariffs").pack(),
|
||||
)
|
||||
builder.button(
|
||||
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()),
|
||||
)
|
||||
|
||||
if admin_role == "superadmin":
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()),
|
||||
InlineKeyboardButton(text="🧩 Мои модули", callback_data=AdminPanelCallback(action="modules").pack()),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
|
||||
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
|
||||
)
|
||||
else:
|
||||
builder.button(
|
||||
text="🎁 Подарки",
|
||||
callback_data=AdminPanelCallback(action="gifts").pack(),
|
||||
)
|
||||
|
||||
module_buttons = await run_hooks("admin_panel", admin_role=admin_role)
|
||||
builder = insert_hook_buttons(builder, module_buttons)
|
||||
|
||||
builder.button(
|
||||
text=MAIN_MENU,
|
||||
callback_data="profile",
|
||||
)
|
||||
|
||||
if admin_role == "superadmin":
|
||||
builder.adjust(2, 1, 1, 1, 2, 2, 2, 1)
|
||||
else:
|
||||
builder.adjust(2, 2, 1, 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,74 @@
|
||||
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 sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Admin
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
from utils.versioning import get_version
|
||||
|
||||
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, session: AsyncSession):
|
||||
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
|
||||
|
||||
await state.clear()
|
||||
|
||||
result = await session.execute(select(Admin.role).where(Admin.tg_id == callback_query.from_user.id))
|
||||
role = result.scalar_one_or_none() or "admin"
|
||||
|
||||
markup = await build_panel_kb(admin_role=role)
|
||||
|
||||
if callback_query.message.text:
|
||||
try:
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=markup,
|
||||
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=markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin", IsAdminFilter())
|
||||
async def handle_admin_callback_query_simple(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
await handle_admin_callback_query(callback_query, state, session)
|
||||
|
||||
|
||||
@router.message(Command("admin"), IsAdminFilter())
|
||||
async def handle_admin_message(message: Message, state: FSMContext, session: AsyncSession):
|
||||
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
|
||||
|
||||
await state.clear()
|
||||
|
||||
result = await session.execute(select(Admin.role).where(Admin.tg_id == message.from_user.id))
|
||||
role = result.scalar_one_or_none() or "admin"
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=await build_panel_kb(admin_role=role),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .restart_handler import router
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
|
||||
|
||||
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,74 @@
|
||||
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="trial").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,377 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
from sqlalchemy import distinct, exists, func, not_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import create_blocked_user
|
||||
from database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, 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()
|
||||
|
||||
|
||||
async def try_add_blocked_user(tg_id: int, session: AsyncSession):
|
||||
if session:
|
||||
try:
|
||||
await create_blocked_user(session, tg_id)
|
||||
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось добавить {tg_id} в blocked_users: {e}")
|
||||
|
||||
|
||||
async def send_broadcast_batch(bot, messages, batch_size=15, session=None):
|
||||
results = []
|
||||
min_interval = 1.0 / 15
|
||||
|
||||
for msg in messages:
|
||||
tg_id = msg["tg_id"]
|
||||
text = msg["text"]
|
||||
photo = msg.get("photo")
|
||||
keyboard = msg.get("keyboard")
|
||||
|
||||
try:
|
||||
if photo:
|
||||
await bot.send_photo(chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
results.append(True)
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
retry_in = int(e.retry_after) + 1
|
||||
logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек. для пользователя {tg_id}")
|
||||
await asyncio.sleep(e.retry_after)
|
||||
try:
|
||||
if photo:
|
||||
await bot.send_photo(
|
||||
chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
|
||||
results.append(True)
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
results.append(False)
|
||||
except TelegramBadRequest as bad_request:
|
||||
error_msg = str(bad_request).lower()
|
||||
if "chat not found" in error_msg:
|
||||
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
|
||||
results.append(False)
|
||||
except Exception as retry_error:
|
||||
logger.error(f"❌ Ошибка повторной отправки пользователю {tg_id}: {retry_error}")
|
||||
results.append(False)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
results.append(False)
|
||||
except TelegramBadRequest as bad_request:
|
||||
error_msg = str(bad_request).lower()
|
||||
if "chat not found" in error_msg:
|
||||
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
|
||||
await try_add_blocked_user(tg_id, session)
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
|
||||
results.append(False)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
|
||||
results.append(False)
|
||||
|
||||
await asyncio.sleep(min_interval)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class AdminSender(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
preview = State()
|
||||
|
||||
|
||||
async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str = None) -> tuple[list[int], int]:
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
banned_tg_ids = select(BlockedUser.tg_id).union_all(
|
||||
select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
|
||||
)
|
||||
|
||||
query = None
|
||||
if send_to == "subscribed":
|
||||
query = (
|
||||
select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
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)).where(~subquery.c.tg_id.in_(banned_tg_ids))
|
||||
elif send_to == "untrial":
|
||||
subquery = select(Key.tg_id)
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
elif send_to == "cluster":
|
||||
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)
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
elif send_to == "hotleads":
|
||||
subquery_active_keys = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
|
||||
query = (
|
||||
select(distinct(User.tg_id))
|
||||
.join(Payment, User.tg_id == Payment.tg_id)
|
||||
.where(Payment.status == "success")
|
||||
.where(Payment.amount > 0)
|
||||
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
|
||||
.where(not_(exists(subquery_active_keys.where(Key.tg_id == User.tg_id))))
|
||||
.where(~User.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
elif send_to == "trial":
|
||||
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
query = (
|
||||
select(distinct(Key.tg_id))
|
||||
.where(Key.tariff_id.in_(trial_tariff_subquery))
|
||||
.where(~Key.tg_id.in_(banned_tg_ids))
|
||||
)
|
||||
else:
|
||||
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(banned_tg_ids))
|
||||
|
||||
result = await session.execute(query)
|
||||
tg_ids = [row[0] for row in result.all()]
|
||||
return tg_ids, len(tg_ids)
|
||||
|
||||
|
||||
def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
|
||||
if "BUTTONS:" not in text:
|
||||
return text, None
|
||||
|
||||
parts = text.split("BUTTONS:", 1)
|
||||
clean_text = parts[0].strip()
|
||||
buttons_text = parts[1].strip()
|
||||
|
||||
if not buttons_text:
|
||||
return clean_text, None
|
||||
|
||||
buttons = []
|
||||
button_lines = [line.strip() for line in buttons_text.split("\n") if line.strip()]
|
||||
|
||||
for line in button_lines:
|
||||
try:
|
||||
cleaned_line = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", line)
|
||||
|
||||
button_data = json.loads(cleaned_line)
|
||||
|
||||
if not isinstance(button_data, dict) or "text" not in button_data:
|
||||
logger.warning(f"Неверный формат кнопки: {line}")
|
||||
continue
|
||||
|
||||
text_btn = button_data["text"]
|
||||
|
||||
if "callback" in button_data:
|
||||
callback_data = button_data["callback"]
|
||||
if len(callback_data) > 64:
|
||||
logger.warning(f"Callback слишком длинный: {callback_data}")
|
||||
continue
|
||||
button = InlineKeyboardButton(text=text_btn, callback_data=callback_data)
|
||||
elif "url" in button_data:
|
||||
url = button_data["url"]
|
||||
button = InlineKeyboardButton(text=text_btn, url=url)
|
||||
else:
|
||||
logger.warning(f"Кнопка без действия: {line}")
|
||||
continue
|
||||
|
||||
buttons.append([button])
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Ошибка парсинга JSON кнопки: {line} - {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания кнопки: {line} - {e}")
|
||||
continue
|
||||
|
||||
if not buttons:
|
||||
return clean_text, None
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
return clean_text, keyboard
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "sender"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_sender(callback_query: CallbackQuery):
|
||||
try:
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Выберите группу пользователей для рассылки:",
|
||||
reply_markup=build_sender_kb(),
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e):
|
||||
logger.debug("[Sender] Сообщение не изменено, Telegram отклонил редактирование")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
@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=(
|
||||
"✍️ Введите текст сообщения для рассылки\n\n"
|
||||
"Поддерживается только Telegram-форматирование — <b>жирный</b>, <i>курсив</i> и другие стили через редактор Telegram.\n\n"
|
||||
"Вы можете отправить:\n"
|
||||
"• Только <b>текст</b>\n"
|
||||
"• Только <b>картинку</b>\n"
|
||||
"• <b>Текст + картинку</b>\n"
|
||||
"• <b>Сообщение + кнопки</b> (см. формат ниже)\n\n"
|
||||
"<b>📋 Пример формата кнопок:</b>\n"
|
||||
"<code>Ваше сообщение</code>\n\n"
|
||||
"<code>BUTTONS:</code>\n"
|
||||
'<code>{"text": "👤 Личный кабинет", "callback": "profile"}</code>\n'
|
||||
'<code>{"text": "➕ Купить подписку", "callback": "buy"}</code>\n'
|
||||
'<code>{"text": "🎁 Забрать купон", "url": "https://t.me/cupons"}</code>\n'
|
||||
'<code>{"text": "📢 Канал", "url": "https://t.me/channel"}</code>'
|
||||
),
|
||||
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: AsyncSession):
|
||||
original_text = message.html_text or message.text or message.caption or ""
|
||||
photo = message.photo[-1].file_id if message.photo else None
|
||||
|
||||
clean_text, keyboard = parse_message_buttons(original_text)
|
||||
|
||||
max_len = 1024 if photo else 4096
|
||||
if len(clean_text) > max_len:
|
||||
await message.answer(
|
||||
f"⚠️ Сообщение слишком длинное.\nМаксимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
send_to = data.get("type", "all")
|
||||
cluster_name = data.get("cluster_name")
|
||||
_, user_count = await get_recipients(session, send_to, cluster_name)
|
||||
|
||||
await state.update_data(text=clean_text, photo=photo, keyboard=keyboard.model_dump() if keyboard else None)
|
||||
await state.set_state(AdminSender.preview)
|
||||
|
||||
if photo:
|
||||
await message.answer_photo(photo=photo, caption=clean_text, parse_mode="HTML", reply_markup=keyboard)
|
||||
else:
|
||||
await message.answer(text=clean_text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
await message.answer(
|
||||
f"👀 Это предпросмотр рассылки.\n👥 Количество получателей: <b>{user_count}</b>\n\nОтправить?",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📤 Отправить", callback_data="send_message"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_message"),
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "send_message", IsAdminFilter())
|
||||
async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
text_message = data.get("text")
|
||||
photo = data.get("photo")
|
||||
keyboard_data = data.get("keyboard")
|
||||
send_to = data.get("type", "all")
|
||||
cluster_name = data.get("cluster_name")
|
||||
|
||||
keyboard = None
|
||||
if keyboard_data:
|
||||
try:
|
||||
keyboard = InlineKeyboardMarkup.model_validate(keyboard_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления клавиатуры: {e}")
|
||||
|
||||
tg_ids, total_users = await get_recipients(session, send_to, cluster_name)
|
||||
|
||||
await callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
|
||||
|
||||
messages = []
|
||||
for tg_id in tg_ids:
|
||||
message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
|
||||
messages.append(message_data)
|
||||
|
||||
results = await send_broadcast_batch(bot=callback_query.bot, messages=messages, batch_size=15, session=session)
|
||||
success_count = sum(1 for result in results if result)
|
||||
|
||||
await callback_query.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()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "cancel_message", IsAdminFilter())
|
||||
async def handle_send_cancel(callback_query: CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.edit_text(
|
||||
"🚫 Рассылка отменена.",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.clear()
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .servers_handler import router
|
||||
@@ -0,0 +1,105 @@
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, 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=f"edit_server|{server_name}",
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="🔙 Назад",
|
||||
callback_data=f"cluster_servers|{cluster_name}",
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_edit_server_fields_kb(server_name: str, server_data: dict) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📝 Имя сервера", callback_data=f"edit_server_field|{server_name}|server_name")
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🗂 Кластер", callback_data=f"edit_server_field|{server_name}|cluster_name"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🌐 API URL", callback_data=f"edit_server_field|{server_name}|api_url"))
|
||||
|
||||
if server_data.get("subscription_url"):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📡 Subscription URL", callback_data=f"edit_server_field|{server_name}|subscription_url"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔑 Inbound ID/Squads", callback_data=f"edit_server_field|{server_name}|inbound_id")
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="⚙️ Тип панели", callback_data=f"select_panel_type|{server_name}"))
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_panel_type_selection_kb(server_name: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🌐 3x-ui", callback_data=f"set_panel_type|{server_name}|3x-ui")],
|
||||
[InlineKeyboardButton(text="🌀 remnawave", callback_data=f"set_panel_type|{server_name}|remnawave")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"edit_server|{server_name}")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_cluster_selection_kb(server_name: str, clusters: list[str]) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for cluster in clusters:
|
||||
builder.row(InlineKeyboardButton(text=cluster, callback_data=f"set_cluster|{server_name}|{cluster}"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"edit_server|{server_name}"))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_cancel_edit_kb(server_name: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="❌ Отменить", callback_data=f"edit_server|{server_name}")]]
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user