Compare commits
1628 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3fc136d1e | |||
| eea908da36 | |||
| 04ff996d14 | |||
| c3dd90a9a9 | |||
| 3c8b1fe640 | |||
| 0f449680ee | |||
| 4fa5c882ba | |||
| 8f031d3d1f | |||
| 534313ce12 | |||
| 586ff2e895 | |||
| 3f62af27b7 | |||
| bf01c6bcfc | |||
| ec8adb0907 | |||
| eafe0c56af | |||
| b09a71aeaf | |||
| dd6ce9ff1d | |||
| 90291798eb | |||
| 1d42ab1f04 | |||
| 74b991252a | |||
| f6b3c31d5a | |||
| 6324426b27 | |||
| b58dc6ab82 | |||
| fc130e86f9 | |||
| bb63732e91 | |||
| a96717bf2a | |||
| a198d1bce0 | |||
| 2041e66956 | |||
| 2f1d8ff9e5 | |||
| 2a68f8e36c | |||
| 472a099a8f | |||
| 3ca8c0f960 | |||
| 13fd27aa96 | |||
| cc57c8610b | |||
| 3f8db29405 | |||
| bb8b07c318 | |||
| 0aa4cf0996 | |||
| 355f210b8c | |||
| b290fc6382 | |||
| 0cc39b4d7f | |||
| 3c00f0cc10 | |||
| b9c0701fe6 | |||
| 0fd8948870 | |||
| 833721bd60 | |||
| 8693115571 | |||
| 7f6f00286c | |||
| 5556bfc6c7 | |||
| 14b97a9c3d | |||
| 4372e651b9 | |||
| 1f47f723d0 | |||
| e4b79d4410 | |||
| 0873cdb16d | |||
| 0265e9cb50 | |||
| 3e1f78f94c | |||
| 81d44d9f7c | |||
| 62cde5d6d5 | |||
| 22d82c9199 | |||
| 84e9637453 | |||
| db16c0d49c | |||
| a967352e9a | |||
| 93acfa3af9 | |||
| 8dca92b5d9 | |||
| 08f0ab2846 | |||
| 6477afd00f | |||
| 6478495e6e | |||
| a60405e4f4 | |||
| f2a9f81ddf | |||
| 80bf976fe5 | |||
| 1b2de84f2d | |||
| 858968665b | |||
| af2d6507da | |||
| 786e2d2288 | |||
| 12c1d5badb | |||
| 2eeccf99ee | |||
| 3eb3fd229e | |||
| d88d1d9482 | |||
| abd0315244 | |||
| 80dc53f272 | |||
| 218a5b544d | |||
| 2e25957855 | |||
| f60a5b7506 | |||
| 316b6359fc | |||
| a6ed138c09 | |||
| 9c8b2c8c50 | |||
| 457048579a | |||
| 78afc78ab7 | |||
| e187d1167b | |||
| 35e9a6d104 | |||
| f32ebc95cd | |||
| 79ddde90ca | |||
| adfadcf127 | |||
| 8a0b9b7c98 | |||
| 9bec6fe341 | |||
| ab39653bab | |||
| a7e3f302d6 | |||
| 38a8fe251a | |||
| 03763a79f6 | |||
| bdbd262fce | |||
| ea87cc0393 | |||
| 5f40605e9f | |||
| 86836da169 | |||
| 92e2e8cb3f | |||
| f6ca1bda22 | |||
| 2025329e5f | |||
| e71a20cd3e | |||
| 20c9b36c26 | |||
| 5c2bde726c | |||
| 7937bceb9c | |||
| 5aa762aaf1 | |||
| 3d2269a368 | |||
| 4f5878bc4e | |||
| 851e452292 | |||
| 985ae18bfb | |||
| 9c999e7220 | |||
| 877c005a6f | |||
| 1b1780df55 | |||
| 9bacc0c8e3 | |||
| 01dea3cccf | |||
| 748adc6697 | |||
| 919335d858 | |||
| ab4418b503 | |||
| 654b20c18f | |||
| 8183c2b678 | |||
| ccacc98b0e | |||
| 0d11a9b645 | |||
| 6a29a7acd4 | |||
| ea91d1f7b0 | |||
| ef59eeac4c | |||
| 26da97187c | |||
| 48ebdeca0f | |||
| 2b1018d7d3 | |||
| 317d905a2a | |||
| 610ccdd1f9 | |||
| d2288c62a3 | |||
| 9f10fed3e7 | |||
| 1140a6c050 | |||
| f2d14d69b0 | |||
| 28cfe91175 | |||
| 7a985eb9c6 | |||
| afd99691f6 | |||
| 153be452b0 | |||
| 679e65ec44 | |||
| f44bd9e4b6 | |||
| 0385e15815 | |||
| b109834543 | |||
| 9f353e8b5c | |||
| 8b77ccda9e | |||
| 1d5c80cd35 | |||
| 041e1c898d | |||
| 76390df4bc | |||
| c3941397cf | |||
| 05e8bfece4 | |||
| f6f9fdce18 | |||
| 4525769370 | |||
| 60447b41f6 | |||
| 045fbddecb | |||
| 84d8024d08 | |||
| 617cdbad10 | |||
| 532f066ffb | |||
| e29e903efa | |||
| 0c7da4b9fd | |||
| ea6538a487 | |||
| e1602da083 | |||
| 96b977adbd | |||
| 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 | |||
| e16f5a50ee | |||
| e4641a0e86 | |||
| 478a6ecd8b | |||
| 12a615b5a8 | |||
| 6a3def212c | |||
| 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 |
@@ -1,6 +0,0 @@
|
||||
[flake8]
|
||||
max-line-length = 120
|
||||
ignore = E203, E266, E501, W503, F541, E704, W293, W291, E126, E121, E123, E128, E302, E131, E231, W292, E402, E261, E305, E701
|
||||
max-complexity = 15
|
||||
select = B, C, E, F, W, T4, B9
|
||||
exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build,.txt,config.py
|
||||
@@ -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).
|
||||
+11
-4
@@ -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
|
||||
@@ -41,7 +43,6 @@ database.db
|
||||
bot_old.py
|
||||
bot_old_2.py
|
||||
backup_pg.sh
|
||||
docker-compose.yml
|
||||
config copy.py
|
||||
handlers/texts.py
|
||||
|
||||
@@ -51,6 +52,12 @@ Thumbs.db
|
||||
|
||||
nginx.conf
|
||||
scripts
|
||||
models.py
|
||||
Dockerfile
|
||||
.csv
|
||||
.csv
|
||||
/logs
|
||||
setup.py
|
||||
.ruff_cache
|
||||
.github/workflows/
|
||||
modules/
|
||||
storage/
|
||||
|
||||
.license_state
|
||||
@@ -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.
|
||||
|
||||
Внося изменения, вы соглашаетесь, что ваш вклад распространяется на условиях этой лицензии. Перепродажа или коммерческое распространение этого кода без явного письменного согласия правообладателя не допускаются.
|
||||
---
|
||||
Ваш вклад помогает проекту расти и становиться лучше.
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY . .
|
||||
|
||||
RUN rm -rf /app/venv \
|
||||
&& python -m venv /app/venv \
|
||||
&& /app/venv/bin/pip install --upgrade pip \
|
||||
&& /app/venv/bin/pip install -r requirements.txt
|
||||
|
||||
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
CMD ["/app/venv/bin/python", "main.py"]
|
||||
@@ -0,0 +1,330 @@
|
||||
Creative Commons Attribution-NonCommercial 4.0 International Public
|
||||
License © Vladless, 2024.
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-NonCommercial 4.0 International Public License ("Public
|
||||
License"). To the extent this Public License may be interpreted as a
|
||||
contract, You are granted the Licensed Rights in consideration of Your
|
||||
acceptance of these terms and conditions, and the Licensor grants You
|
||||
such rights in consideration of benefits the Licensor receives from
|
||||
making the Licensed Material available under these terms and
|
||||
conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
d. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
f. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
g. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
h. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
i. NonCommercial means not primarily intended for or directed towards
|
||||
commercial advantage or monetary compensation. For purposes of
|
||||
this Public License, the exchange of the Licensed Material for
|
||||
other material subject to Copyright and Similar Rights by digital
|
||||
file-sharing or similar means is NonCommercial provided there is
|
||||
no payment of monetary compensation in connection with the
|
||||
exchange.
|
||||
|
||||
j. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
k. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
l. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part, for NonCommercial purposes only; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material for
|
||||
NonCommercial purposes only.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties, including when
|
||||
the Licensed Material is used other than for NonCommercial
|
||||
purposes.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
4. If You Share Adapted Material You produce, the Adapter's
|
||||
License You apply must not prevent recipients of the Adapted
|
||||
Material from complying with this Public License.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database for NonCommercial purposes
|
||||
only;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material; and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
@@ -1,5 +1,10 @@
|
||||
formatting:
|
||||
@echo "Running black..." && black .
|
||||
@echo "Running isort..." && isort .
|
||||
@echo "Running flake8..." && flake8 --config .flake8
|
||||
# @echo "Running pylint..." && pylint .
|
||||
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 . --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,148 +1,211 @@
|
||||
# 🚀 SoloBot
|
||||
<div align="center" style="max-width:980px;margin:0 auto;padding:0 12px">
|
||||
|
||||
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
|
||||

|
||||
|
||||
Три версии — море возможностей:
|
||||
- v1.4 — бот для продажи ключей vless:
|
||||
|
||||

|
||||
- v2.3.1 — стабильная версия подписок вместо ключей с кнопками автодобавления в приложение:
|
||||
|
||||

|
||||
- v3.1 — настолько большая, что смотрите в [релизе](https://github.com/Vladless/Solo_bot/releases/tag/v3.1):
|
||||
<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>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
Если вам не хватает функций — направьте их в issue, мы реализуем
|
||||
<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>
|
||||
|
||||
## 📋 Оглавление
|
||||
1. [Описание](#описание)
|
||||
2. [Стек технологий](#стек-технологий)
|
||||
3. [Установка](#установка)
|
||||
4. [Конфигурация](#конфигурация)
|
||||
5. [Запуск](#запуск)
|
||||
6. [Контакты](#контакты)
|
||||
|
||||
---
|
||||
|
||||
## 📖 Описание
|
||||
|
||||
SoloBot реализует множество функций, включая:
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
<p><b>Многофункциональный бот, кастомизируемый под ваш бренд.</b></p>
|
||||
</div>
|
||||
|
||||
|
||||
- **Выдача ключей на пробный период**.
|
||||
- **Выдача ключей на длительные периоды** (1 месяц, 3 месяца, полгода, год).
|
||||
- **Продление ключей** на указанные периоды.
|
||||
- Полный контроль клиента над своими ключами:
|
||||
- Удаление ключей.
|
||||
- Продление ключей.
|
||||
- Просмотр информации о ключе (сервер, оставшееся время, сам ключ).
|
||||
- **Смена локации** (перемещение ключа между серверами).
|
||||
- Поддержка нескольких ключей для одного клиента (несколько устройств).
|
||||
- **Реферальная программа** с пригласительной ссылкой.
|
||||
- Доступ к **инструкциям**.
|
||||
- **Пополнение баланса**:
|
||||
* через Юкасса (самозанятость и ИП)
|
||||
* через freekassa (Физические Лица)
|
||||
* Криптовалюта (Thanks [izzzzzi](https://github.com/izzzzzi))
|
||||
- Периодические **бэкапы базы данных клиентов**.
|
||||
- Уведомления:
|
||||
- Произвольные сообщения через админку.
|
||||
- Уведомления о неиспользованных пробниках.
|
||||
- Уведомления о истекающих ключах (за сутки, за 6 часов и в момент истечения).
|
||||
- **Чат поддержки** и канал для связи.
|
||||
- **Автоматическое продление ключа** при наличии достаточного баланса.
|
||||
- **Удобная админка прямо в боте**
|
||||
- **мультисерверность** добавляй сервера в конфиг, и они автоматически будут в боте
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<tr>
|
||||
<td><b>Клиент</b></td>
|
||||
<td>Ключ / сервер / TTL<br>Продление / удаление / начисление дней / отключение<br>Смена локации<br>Несколько устройств</td>
|
||||
</tr>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<tr>
|
||||
<td><b>Серверы</b></td>
|
||||
<td>Мультисерверность<br>Одна локация или подписка<br>Автопроверка и балансировка<br>Синхронизация и лимиты<br>Вкл/выкл</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Воронка продаж</b></td>
|
||||
<td>Доп. дни к триалу: не взял триал → оффер → активация<br>Горячие лиды: платил → перестал → таргет/КП → возврат</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Админка</b></td>
|
||||
<td>Поиск TG/username/ключ/email<br>Баланс / подписки / заморозки<br>Перезагрузка<br>Бан-лист · купоны · UTM · статистика<br>Синхронизация / импорт из панели</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Модульность</b></td>
|
||||
<td>Подключаемые модули<br>Вкл/выкл и перезапуск из админки<br>Обновления и версии<br>Возможность дорабатывать код не теряя обновления</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Кастомизация</b></td>
|
||||
<td>Гибкая настройка функций и логики<br>Возможность отключения отдельных функций</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<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">Регулярные релизы. Обновление через 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
|
||||
<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">
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 💻 Стек технологий
|
||||
|
||||
Проект использует следующие технологии:
|
||||
|
||||
- **Python** версии 3.8 или выше.
|
||||
- **Git** для клонирования репозитория.
|
||||
- **Virtualenv** для создания виртуального окружения (рекомендуется).
|
||||
- **PostgreSQL** для хранения данных.
|
||||
- **Nginx** для работы с вебхуками.
|
||||
- **aiogram** (версия 3.13) для взаимодействия с Telegram API.
|
||||
- **youkassa** для обработки платежей.
|
||||
- **aiohttp** для API запросов к панелям.
|
||||
|
||||
Проект полностью асинхронный, что обеспечивает высокую скорость работы.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Установка
|
||||
|
||||
### 1️⃣ Шаг 1: Клонирование репозитория
|
||||
|
||||
Клонируйте репозиторий и перейдите в его директорию:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Vladless/Solo_bot.git
|
||||
cd solo_bot
|
||||
```
|
||||
|
||||
### 2️⃣ Шаг 2: Создание и активация виртуального окружения
|
||||
|
||||
```
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 3️⃣ Шаг 3: Установка зависимостей
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 🛠️ Конфигурация
|
||||
|
||||
Для правильной работы вам нужно:
|
||||
|
||||
* установить и запустить postgresql, создать пользователя для работы с базой данных и выдать ему права
|
||||
* Настроить ваш сервер на работу с ботом, выпустить SSL сертификат для домена
|
||||
* Настроить вебхуки и пути до них в NGINX
|
||||
|
||||
* Создать файл config.py в корневой папке проекта с вашими данными:
|
||||
|
||||
```
|
||||
|
||||
API_TOKEN = токен вашего бота телеграм
|
||||
|
||||
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/"
|
||||
DATABASE_URL = путь к вашей базе данных, имеет вид "postgresql://{user}:{password}@{Ip-adress}:{port}/{имя базы данных}"
|
||||
ADMIN_ID = ID телеграм профиля администратора
|
||||
CHANNEL_URL = ссылка на ваш телеграм канал
|
||||
YOOKASSA_SECRET_KEY = ваш ключ юкассы
|
||||
YOOKASSA_SHOP_ID = ваш шопайди
|
||||
WEBHOOK_HOST = адрес вашего сервера для вебхуков
|
||||
WEBHOOK_PATH = '/webhook/'
|
||||
WEBHOOK_URL = f"{WEBHOOK_HOST}{WEBHOOK_PATH}"
|
||||
SUPPORT_CHAT_URL = ваша ссылка на поддержку
|
||||
|
||||
```
|
||||
**Мы высылаем детальный гайд и недостающие файлы в боте**
|
||||
|
||||
**Все описания в одном файле!** Удобно настроить бот под свой сервис изменив информацию и цены в одном месте
|
||||
|
||||
### 🚀 Запуск
|
||||
|
||||
введите команду из виртуального окружения
|
||||
|
||||
```
|
||||
python main.py
|
||||
```
|
||||
### 🔗 SoloBot в Telegram и Полная версия
|
||||
|
||||
Попробуйте SoloBot прямо сейчас в Telegram [по этой ссылке](https://t.me/SoloNetVPN_bot).
|
||||
|
||||
Вы также можете поддержать автора или купить полную версию бота с сопровождением по установке в поддержке бота!
|
||||
Полная пошаговая инстуркция по запуску бота предоставляется в поддержке бота
|
||||
|
||||
+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
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from api.routes import (
|
||||
users,
|
||||
keys,
|
||||
coupons,
|
||||
servers,
|
||||
tariffs,
|
||||
gifts,
|
||||
referrals,
|
||||
misc,
|
||||
partners,
|
||||
modules,
|
||||
management,
|
||||
settings,
|
||||
)
|
||||
from config import API_LOGGING
|
||||
from logger import logger
|
||||
|
||||
app = FastAPI(
|
||||
title="SoloBot API (Alpha)",
|
||||
version="0.5.2",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def api_access_log_middleware(request: Request, call_next):
|
||||
if not API_LOGGING:
|
||||
return await call_next(request)
|
||||
|
||||
started = perf_counter()
|
||||
response = await call_next(request)
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
client_ip = request.client.host if request.client else "-"
|
||||
path_qs = request.url.path
|
||||
if request.url.query:
|
||||
path_qs = f"{path_qs}?{request.url.query}"
|
||||
|
||||
logger.info(
|
||||
f'[API] {client_ip} "{request.method} {path_qs}" {response.status_code} {duration_ms}ms'
|
||||
)
|
||||
return response
|
||||
|
||||
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(partners.router, prefix="/api/partners", tags=["Partners"])
|
||||
app.include_router(misc.router, prefix="/api")
|
||||
app.include_router(modules.router, prefix="/api")
|
||||
app.include_router(management.router, prefix="/api/management", tags=["Management"])
|
||||
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
|
||||
|
||||
@app.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "Welcome to SoloBot API. Docs: /api/docs"}
|
||||
@@ -0,0 +1,164 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, 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 normalize_outgoing_object(obj: object) -> None:
|
||||
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
|
||||
setattr(obj, "vless", False)
|
||||
|
||||
|
||||
def to_schema(schema_response: type, obj: object):
|
||||
normalize_outgoing_object(obj)
|
||||
return schema_response.model_validate(obj, from_attributes=True)
|
||||
|
||||
|
||||
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))
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
return [schema_response.model_validate(item, from_attributes=True) for item in items]
|
||||
|
||||
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 to_schema(schema_response, 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 to_schema(schema_response, 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")
|
||||
for obj in objs:
|
||||
normalize_outgoing_object(obj)
|
||||
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
|
||||
|
||||
if "create" in enabled_methods:
|
||||
|
||||
@router.post("/", response_model=schema_response)
|
||||
async def create(
|
||||
payload: Any = Body(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
validated = schema_create.model_validate(payload)
|
||||
data = validated.model_dump(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 to_schema(schema_response, obj)
|
||||
|
||||
if "update" in enabled_methods:
|
||||
|
||||
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def update(
|
||||
payload: Any = Body(...),
|
||||
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")
|
||||
|
||||
validated = schema_update.model_validate(payload)
|
||||
for k, v in validated.model_dump(exclude_unset=True).items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return to_schema(schema_response, 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,219 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import asyncio
|
||||
from typing import Literal
|
||||
|
||||
import psutil
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import distinct, exists, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from config import API_TOKEN
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from core.settings.management_config import update_management_config
|
||||
from database.models import Key, User
|
||||
from database.models import Server
|
||||
from handlers.admin.sender.sender_service import BroadcastService
|
||||
from handlers.admin.sender.sender_utils import get_recipients, parse_message_buttons
|
||||
from logger import logger
|
||||
from utils.backup import backup_database
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
|
||||
|
||||
class BroadcastLaunchPayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
|
||||
text: str
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int = 5
|
||||
messages_per_second: int = 35
|
||||
|
||||
|
||||
_broadcast_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_broadcast_bot() -> Bot:
|
||||
global _broadcast_bot
|
||||
if _broadcast_bot is None:
|
||||
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return _broadcast_bot
|
||||
|
||||
|
||||
async def _restart_bot() -> None:
|
||||
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:
|
||||
os._exit(1)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(admin=Depends(verify_admin_token)):
|
||||
return {
|
||||
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
|
||||
"management": dict(MANAGEMENT_CONFIG or {}),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance")
|
||||
async def set_maintenance(
|
||||
payload: MaintenanceUpdate,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current_config = dict(MANAGEMENT_CONFIG or {})
|
||||
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
|
||||
await update_management_config(session, current_config)
|
||||
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_bot(
|
||||
background: BackgroundTasks,
|
||||
admin=Depends(verify_admin_token),
|
||||
):
|
||||
background.add_task(_restart_bot)
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@router.post("/change-domain")
|
||||
async def change_domain(
|
||||
payload: DomainChange,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
domain = payload.domain.strip()
|
||||
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
|
||||
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||
|
||||
new_domain_url = f"https://{domain}"
|
||||
|
||||
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))
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
return {"updated": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/restore-trials")
|
||||
async def restore_trials(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
return {"restored": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/backup")
|
||||
async def trigger_backup(admin=Depends(verify_admin_token)):
|
||||
async def _run_backup() -> None:
|
||||
exception = await backup_database()
|
||||
if exception:
|
||||
logger.error(f"[Management] Backup finished with error: {exception}")
|
||||
|
||||
asyncio.create_task(_run_backup())
|
||||
return {"status": "backup_started"}
|
||||
|
||||
|
||||
@router.get("/broadcast/clusters")
|
||||
async def get_broadcast_clusters(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
|
||||
clusters = sorted([row[0] for row in result.all() if row and row[0]])
|
||||
return {"clusters": clusters}
|
||||
|
||||
|
||||
@router.post("/broadcast")
|
||||
async def launch_broadcast(
|
||||
payload: BroadcastLaunchPayload,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
text_raw = (payload.text or "").strip()
|
||||
if not text_raw:
|
||||
raise HTTPException(status_code=400, detail="Broadcast text is required")
|
||||
|
||||
if payload.send_to == "cluster" and not (payload.cluster_name or "").strip():
|
||||
raise HTTPException(status_code=400, detail="Cluster name is required for cluster broadcast")
|
||||
|
||||
clean_text, keyboard = parse_message_buttons(text_raw)
|
||||
|
||||
max_len = 1024 if payload.photo else 4096
|
||||
if len(clean_text) > max_len:
|
||||
raise HTTPException(status_code=400, detail=f"Message too long. Max {max_len} symbols")
|
||||
|
||||
tg_ids, total_users = await get_recipients(session, payload.send_to, (payload.cluster_name or None))
|
||||
if not tg_ids:
|
||||
return {"success": False, "message": "No recipients found", "stats": {"total_messages": 0}}
|
||||
|
||||
bot = _get_broadcast_bot()
|
||||
messages = [
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"text": clean_text,
|
||||
"photo": payload.photo,
|
||||
"keyboard": keyboard,
|
||||
}
|
||||
for tg_id in tg_ids
|
||||
]
|
||||
|
||||
workers = max(1, min(int(payload.workers or 5), 30))
|
||||
rate = max(1, min(int(payload.messages_per_second or 35), 60))
|
||||
broadcast_service = BroadcastService(bot=bot, session=session, messages_per_second=rate)
|
||||
stats = await broadcast_service.broadcast(messages, workers=workers)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Broadcast completed",
|
||||
"recipients": total_users,
|
||||
"stats": stats,
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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,
|
||||
TemporaryDataResponse,
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from database import get_tracking_source_stats
|
||||
from database.models import (
|
||||
Admin,
|
||||
BlockedUser,
|
||||
ManualBan,
|
||||
Notification,
|
||||
Payment,
|
||||
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=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,124 @@
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.depends import verify_admin_token
|
||||
from utils.modules_loader import _is_safe_module_name
|
||||
from utils.modules_manager import manager
|
||||
|
||||
|
||||
router = APIRouter(prefix="/modules", tags=["Modules"])
|
||||
|
||||
|
||||
MODULES_DIR = Path(__file__).resolve().parents[2] / "modules"
|
||||
|
||||
|
||||
class ModuleAction(BaseModel):
|
||||
action: Literal["start", "stop", "restart"]
|
||||
|
||||
|
||||
def _available_module_names() -> list[str]:
|
||||
candidates: set[str] = set()
|
||||
if MODULES_DIR.is_dir():
|
||||
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
|
||||
name = (name or "").strip()
|
||||
if name and _is_safe_module_name(name):
|
||||
candidates.add(name)
|
||||
|
||||
return sorted(n for n in candidates if _is_safe_module_name(n))
|
||||
|
||||
|
||||
def _prune_missing_state(installed: set[str]) -> None:
|
||||
changed = False
|
||||
|
||||
stale_disabled = {name for name in manager.disabled if name not in installed}
|
||||
if stale_disabled:
|
||||
for name in stale_disabled:
|
||||
manager.disabled.discard(name)
|
||||
changed = True
|
||||
|
||||
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
|
||||
if stale_registry:
|
||||
for name in stale_registry:
|
||||
manager.registry.pop(name, None)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_state = getattr(manager, "_save_state", None)
|
||||
if callable(save_state):
|
||||
save_state()
|
||||
|
||||
|
||||
def _module_state(name: str) -> dict:
|
||||
normalized = name.strip()
|
||||
record = manager.registry.get(normalized)
|
||||
is_enabled = manager.is_enabled(normalized)
|
||||
return {
|
||||
"name": normalized,
|
||||
"enabled": is_enabled,
|
||||
"loaded": bool(record and record.enabled),
|
||||
"autostart": manager.should_autostart(normalized),
|
||||
}
|
||||
|
||||
|
||||
def _read_local_module_version(name: str) -> str | None:
|
||||
version_file = MODULES_DIR / name / "VERSION"
|
||||
if not version_file.exists() or not version_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
with version_file.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
value = line.strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_modules(admin=Depends(verify_admin_token)):
|
||||
refresh = getattr(manager, "refresh_state", None)
|
||||
if callable(refresh):
|
||||
refresh()
|
||||
else:
|
||||
legacy_refresh = getattr(manager, "_load_state", None)
|
||||
if callable(legacy_refresh):
|
||||
legacy_refresh()
|
||||
module_names = _available_module_names()
|
||||
_prune_missing_state(set(module_names))
|
||||
modules = [_module_state(name) for name in module_names]
|
||||
|
||||
for item in modules:
|
||||
name = str(item.get("name") or "").strip()
|
||||
local_version = _read_local_module_version(name)
|
||||
item["local_version"] = local_version
|
||||
|
||||
return {"items": modules}
|
||||
|
||||
|
||||
@router.post("/{module_name}/actions")
|
||||
async def control_module(module_name: str, payload: ModuleAction, admin=Depends(verify_admin_token)):
|
||||
name = (module_name or "").strip()
|
||||
if not _is_safe_module_name(name):
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
try:
|
||||
if payload.action == "start":
|
||||
await manager.start(name)
|
||||
elif payload.action == "stop":
|
||||
await manager.stop(name)
|
||||
elif payload.action == "restart":
|
||||
await manager.restart(name)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported action")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"item": _module_state(name)}
|
||||
@@ -0,0 +1,962 @@
|
||||
from datetime import datetime
|
||||
import csv
|
||||
from io import StringIO
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
|
||||
try:
|
||||
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
|
||||
except Exception:
|
||||
PARTNER_BONUS_PERCENTAGES = {1: 0.0}
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _parse_percent(value: float) -> float | None:
|
||||
"""Normalize percent input to 0-100 range."""
|
||||
try:
|
||||
val = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if 0.0 <= val <= 1.0:
|
||||
val *= 100.0
|
||||
|
||||
if 0.0 <= val <= 100.0:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _default_partner_percent() -> float:
|
||||
try:
|
||||
return float(PARTNER_BONUS_PERCENTAGES.get(1, 0.0)) * 100.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _row_dt_iso(value) -> str | None:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
async def get_all_partners(
|
||||
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
|
||||
offset: int = Query(0, ge=0, description="Смещение"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список всех партнёров со статистикой.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"total": int,
|
||||
"items": [
|
||||
{
|
||||
"tg_id": int,
|
||||
"balance": float,
|
||||
"percent": float,
|
||||
"code": str | None,
|
||||
"method": str | None,
|
||||
"referred_count": int
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
partners_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
p.partner_tg_id AS tg_id,
|
||||
COALESCE(u.partner_balance, 0) AS partner_balance,
|
||||
u.partner_percent,
|
||||
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
|
||||
u.partner_code,
|
||||
u.payout_method,
|
||||
COUNT(p.joined_tg_id) as joined_count
|
||||
FROM partners p
|
||||
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
|
||||
WHERE p.partner_tg_id IS NOT NULL
|
||||
GROUP BY p.partner_tg_id, u.partner_balance, u.partner_percent, u.partner_percent_custom, u.partner_code, u.payout_method
|
||||
ORDER BY partner_balance DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
count_sql = text(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT partner_tg_id) FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(partners_sql, {"limit": limit, "offset": offset})
|
||||
partners = result.fetchall()
|
||||
|
||||
count_result = await session.execute(count_sql)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
partners_list = []
|
||||
default_percent = _default_partner_percent()
|
||||
for partner in partners:
|
||||
percent_value = partner[2]
|
||||
percent_custom = bool(partner[3])
|
||||
if percent_custom and percent_value is not None:
|
||||
percent = float(percent_value)
|
||||
else:
|
||||
percent = float(default_percent)
|
||||
|
||||
partners_list.append({
|
||||
"tg_id": int(partner[0]),
|
||||
"balance": float(partner[1] or 0),
|
||||
"percent": percent,
|
||||
"code": partner[4] or None,
|
||||
"method": partner[5] or None,
|
||||
"referred_count": int(partner[6] or 0),
|
||||
})
|
||||
|
||||
return JSONResponse(content={"total": total, "items": partners_list})
|
||||
|
||||
|
||||
@router.get("/stats/all")
|
||||
async def get_partners_stats(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает общую статистику партнёрской программы.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"total_partners": int,
|
||||
"partners_today": int,
|
||||
"total_referred": int,
|
||||
"total_balance": float,
|
||||
"top_partner_tg_id": int,
|
||||
"top_partner_refs": int
|
||||
}
|
||||
"""
|
||||
|
||||
stats_sql = text(
|
||||
"""
|
||||
WITH partner_refs AS (
|
||||
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS ref_count
|
||||
FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
GROUP BY partner_tg_id
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM partner_refs) AS total_partners,
|
||||
(
|
||||
SELECT COUNT(DISTINCT partner_tg_id)
|
||||
FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
AND DATE(created_at) = CURRENT_DATE
|
||||
) AS partners_today,
|
||||
(SELECT COUNT(DISTINCT joined_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL) AS total_referred,
|
||||
(
|
||||
SELECT COALESCE(SUM(u.partner_balance), 0.0)
|
||||
FROM users u
|
||||
WHERE u.tg_id IN (SELECT partner_tg_id FROM partner_refs)
|
||||
) AS total_balance,
|
||||
(SELECT partner_tg_id FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_tg_id,
|
||||
(SELECT ref_count FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_refs
|
||||
"""
|
||||
)
|
||||
|
||||
stats_result = await session.execute(stats_sql)
|
||||
stats_row = stats_result.fetchone()
|
||||
|
||||
if stats_row:
|
||||
stats = {
|
||||
"total_partners": int(stats_row[0] or 0),
|
||||
"partners_today": int(stats_row[1] or 0),
|
||||
"total_referred": int(stats_row[2] or 0),
|
||||
"total_balance": float(stats_row[3] or 0.0),
|
||||
"top_partner_tg_id": int(stats_row[4] or 0),
|
||||
"top_partner_refs": int(stats_row[5] or 0),
|
||||
}
|
||||
else:
|
||||
stats = {
|
||||
"total_partners": 0,
|
||||
"partners_today": 0,
|
||||
"total_referred": 0,
|
||||
"total_balance": 0.0,
|
||||
"top_partner_tg_id": 0,
|
||||
"top_partner_refs": 0,
|
||||
}
|
||||
|
||||
return JSONResponse(content=stats)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}")
|
||||
async def update_partner(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
balance: float = Query(..., description="Новый баланс партнёра"),
|
||||
percent: float = Query(..., description="Новый процент партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновляет данные партнёра (баланс и процент).
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
stmt = text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET partner_balance = :balance, partner_percent = :percent
|
||||
WHERE tg_id = :tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(stmt, {"tg_id": tg_id, "balance": balance, "percent": percent})
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": f"Партнёр {tg_id} успешно обновлён"},
|
||||
status_code=200,
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Партнёр не найден"},
|
||||
status_code=404,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tg_id}")
|
||||
async def get_partner_data(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает партнёрские данные для указанного `tg_id`.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"tg_id": int,
|
||||
"partner_balance": float,
|
||||
"partner_percent": float,
|
||||
"partner_code": str | None,
|
||||
"payout_method": str | None,
|
||||
"invited": [
|
||||
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
meta_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(u.partner_balance, 0) AS partner_balance,
|
||||
u.partner_percent,
|
||||
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
|
||||
u.partner_code,
|
||||
u.payout_method
|
||||
FROM users u
|
||||
WHERE u.tg_id = :tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
invited_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
pr.joined_tg_id,
|
||||
pr.created_at,
|
||||
COALESCE(u.balance, 0) AS user_balance,
|
||||
(
|
||||
SELECT COUNT(*) FROM keys k
|
||||
WHERE k.tg_id = pr.joined_tg_id
|
||||
) AS subs_count,
|
||||
(
|
||||
SELECT COUNT(*) FROM payments pay
|
||||
WHERE pay.tg_id = pr.joined_tg_id
|
||||
AND lower(pay.status) = 'success'
|
||||
) AS payments_count
|
||||
FROM partners pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
|
||||
WHERE pr.partner_tg_id = :tg_id
|
||||
ORDER BY pr.created_at DESC
|
||||
"""
|
||||
)
|
||||
|
||||
meta_res = await session.execute(meta_sql, {"tg_id": tg_id})
|
||||
meta_row = meta_res.fetchone()
|
||||
|
||||
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
|
||||
invited_rows = invited_res.fetchall()
|
||||
|
||||
default_percent = _default_partner_percent()
|
||||
percent = default_percent
|
||||
if meta_row:
|
||||
percent_value = meta_row[1]
|
||||
percent_custom = bool(meta_row[2])
|
||||
if percent_custom and percent_value is not None:
|
||||
percent = float(percent_value)
|
||||
|
||||
response = {
|
||||
"tg_id": tg_id,
|
||||
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
|
||||
"partner_percent": percent,
|
||||
"partner_code": meta_row[3] if meta_row else None,
|
||||
"payout_method": meta_row[4] if meta_row else None,
|
||||
"invited": [
|
||||
{
|
||||
"tg_id": row[0],
|
||||
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
|
||||
"balance": float(row[2] or 0),
|
||||
"subs_count": int(row[3] or 0),
|
||||
"payments_count": int(row[4] or 0),
|
||||
}
|
||||
for row in invited_rows
|
||||
],
|
||||
}
|
||||
|
||||
return JSONResponse(content=response)
|
||||
|
||||
|
||||
@router.post("/{tg_id}/invited")
|
||||
async def add_partner_invited(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
joined_tg_id: int = Query(..., description="Telegram ID приглашённого"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Добавляет приглашённого пользователю партнёра."""
|
||||
|
||||
if joined_tg_id == tg_id:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Нельзя привязать пользователя к самому себе"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
partner_exists = await session.execute(
|
||||
text("SELECT 1 FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
if not partner_exists.scalar():
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Партнёр не найден"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
invited_exists = await session.execute(
|
||||
text("SELECT 1 FROM users WHERE tg_id = :joined_tg_id"),
|
||||
{"joined_tg_id": joined_tg_id},
|
||||
)
|
||||
if not invited_exists.scalar():
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Приглашённый пользователь не найден"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
existing = await session.execute(
|
||||
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id"),
|
||||
{"joined_tg_id": joined_tg_id},
|
||||
)
|
||||
existing_partner = existing.scalar()
|
||||
if existing_partner is not None:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Пользователь уже привязан к партнёру {existing_partner}",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO partners (partner_tg_id, joined_tg_id)
|
||||
VALUES (:partner_tg_id, :joined_tg_id)
|
||||
"""
|
||||
),
|
||||
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
|
||||
)
|
||||
await session.commit()
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Приглашённый добавлен",
|
||||
"partner_tg_id": tg_id,
|
||||
"joined_tg_id": joined_tg_id,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.delete("/{tg_id}/invited/{joined_tg_id}")
|
||||
async def delete_partner_invited(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
joined_tg_id: int = Path(..., description="Telegram ID приглашённого"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаляет приглашённого у партнёра."""
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM partners
|
||||
WHERE partner_tg_id = :partner_tg_id
|
||||
AND joined_tg_id = :joined_tg_id
|
||||
"""
|
||||
),
|
||||
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Приглашённый удалён",
|
||||
"partner_tg_id": tg_id,
|
||||
"joined_tg_id": joined_tg_id,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Связка партнёр-приглашённый не найдена"},
|
||||
status_code=404,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/percent")
|
||||
async def update_partner_percent(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
percent: float = Query(..., description="Новый персональный процент (0-100 или 0.0-1.0)"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновляет персональный процент партнёра."""
|
||||
|
||||
normalized = _parse_percent(percent)
|
||||
if normalized is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Неверный процент. Допустимо 0-100 или 0.0-1.0"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET partner_percent = :percent, partner_percent_custom = true
|
||||
WHERE tg_id = :tg_id
|
||||
"""
|
||||
),
|
||||
{"tg_id": tg_id, "percent": normalized},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Процент обновлён", "percent": normalized},
|
||||
status_code=200,
|
||||
)
|
||||
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/balance")
|
||||
async def update_partner_balance(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
amount: float = Query(..., description="Сумма операции"),
|
||||
mode: str = Query("set", description="Режим: set, add, subtract"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Изменяет баланс партнёрской программы."""
|
||||
|
||||
mode_normalized = (mode or "set").strip().lower()
|
||||
if mode_normalized not in {"set", "add", "subtract"}:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Неверный режим. Используйте set, add или subtract"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
amount_val = float(amount)
|
||||
except (TypeError, ValueError):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Неверная сумма"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if amount_val < 0:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Сумма не может быть отрицательной"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
current_res = await session.execute(
|
||||
text("SELECT partner_balance FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
current_balance = current_res.scalar()
|
||||
if current_balance is None:
|
||||
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
|
||||
current_balance = float(current_balance or 0.0)
|
||||
|
||||
if mode_normalized == "set":
|
||||
new_balance = amount_val
|
||||
elif mode_normalized == "add":
|
||||
new_balance = current_balance + amount_val
|
||||
else:
|
||||
if current_balance < amount_val:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Недостаточно средств"},
|
||||
status_code=400,
|
||||
)
|
||||
new_balance = current_balance - amount_val
|
||||
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
|
||||
{"tg_id": tg_id, "balance": new_balance},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Баланс обновлён", "balance": new_balance},
|
||||
status_code=200,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/{tg_id}/invited")
|
||||
async def get_partner_invited(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список приглашённых пользователей конкретного партнёра.
|
||||
|
||||
Структура ответа:
|
||||
[
|
||||
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
|
||||
]
|
||||
"""
|
||||
|
||||
invited_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
pr.joined_tg_id,
|
||||
pr.created_at,
|
||||
COALESCE(u.balance, 0) AS user_balance,
|
||||
(
|
||||
SELECT COUNT(*) FROM keys k
|
||||
WHERE k.tg_id = pr.joined_tg_id
|
||||
) AS subs_count,
|
||||
(
|
||||
SELECT COUNT(*) FROM payments pay
|
||||
WHERE pay.tg_id = pr.joined_tg_id
|
||||
AND lower(pay.status) = 'success'
|
||||
) AS payments_count
|
||||
FROM partners pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
|
||||
WHERE pr.partner_tg_id = :tg_id
|
||||
ORDER BY pr.created_at DESC
|
||||
"""
|
||||
)
|
||||
|
||||
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
|
||||
invited_rows = invited_res.fetchall()
|
||||
|
||||
invited_list = [
|
||||
{
|
||||
"tg_id": row[0],
|
||||
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
|
||||
"balance": float(row[2] or 0),
|
||||
"subs_count": int(row[3] or 0),
|
||||
"payments_count": int(row[4] or 0),
|
||||
}
|
||||
for row in invited_rows
|
||||
]
|
||||
|
||||
return JSONResponse(content=invited_list)
|
||||
|
||||
|
||||
@router.get("/payouts/pending")
|
||||
async def get_partner_payouts_pending(
|
||||
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
|
||||
offset: int = Query(0, ge=0, description="Смещение"),
|
||||
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список ожидающих заявок на вывод."""
|
||||
|
||||
where_clause = "WHERE pr.status = 'pending'"
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if partner_tg_id is not None:
|
||||
where_clause += " AND pr.tg_id = :partner_tg_id"
|
||||
params["partner_tg_id"] = partner_tg_id
|
||||
|
||||
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
|
||||
rows_sql = text(
|
||||
f"""
|
||||
SELECT
|
||||
pr.id,
|
||||
pr.tg_id,
|
||||
pr.amount,
|
||||
pr.status,
|
||||
pr.created_at,
|
||||
COALESCE(pr.method, u.payout_method) AS method,
|
||||
COALESCE(pr.destination, u.card_number) AS destination
|
||||
FROM payout_requests pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.tg_id
|
||||
{where_clause}
|
||||
ORDER BY pr.created_at ASC, pr.id ASC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
total = await session.scalar(count_sql) or 0
|
||||
result = await session.execute(rows_sql, params)
|
||||
items = []
|
||||
for row in result.fetchall():
|
||||
items.append({
|
||||
"id": int(row[0]),
|
||||
"tg_id": int(row[1]),
|
||||
"amount": float(row[2] or 0.0),
|
||||
"status": row[3] or "pending",
|
||||
"created_at": _row_dt_iso(row[4]),
|
||||
"method": row[5] or None,
|
||||
"destination": row[6] or None,
|
||||
})
|
||||
|
||||
return JSONResponse(content={"total": int(total), "items": items})
|
||||
|
||||
|
||||
@router.get("/payouts/history")
|
||||
async def get_partner_payouts_history(
|
||||
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
|
||||
offset: int = Query(0, ge=0, description="Смещение"),
|
||||
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает историю выплат (approved/rejected)."""
|
||||
|
||||
where_clause = "WHERE pr.status IN ('approved','rejected')"
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if partner_tg_id is not None:
|
||||
where_clause += " AND pr.tg_id = :partner_tg_id"
|
||||
params["partner_tg_id"] = partner_tg_id
|
||||
|
||||
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
|
||||
rows_sql = text(
|
||||
f"""
|
||||
SELECT
|
||||
pr.id,
|
||||
pr.tg_id,
|
||||
pr.amount,
|
||||
pr.status,
|
||||
pr.created_at,
|
||||
COALESCE(pr.method, u.payout_method) AS method,
|
||||
COALESCE(pr.destination, u.card_number) AS destination
|
||||
FROM payout_requests pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.tg_id
|
||||
{where_clause}
|
||||
ORDER BY pr.created_at DESC, pr.id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
total = await session.scalar(count_sql) or 0
|
||||
result = await session.execute(rows_sql, params)
|
||||
items = []
|
||||
for row in result.fetchall():
|
||||
items.append({
|
||||
"id": int(row[0]),
|
||||
"tg_id": int(row[1]),
|
||||
"amount": float(row[2] or 0.0),
|
||||
"status": row[3] or "—",
|
||||
"created_at": _row_dt_iso(row[4]),
|
||||
"method": row[5] or None,
|
||||
"destination": row[6] or None,
|
||||
})
|
||||
|
||||
return JSONResponse(content={"total": int(total), "items": items})
|
||||
|
||||
|
||||
@router.post("/payouts/{payout_id}/approve")
|
||||
async def approve_partner_payout(
|
||||
payout_id: int = Path(..., description="ID заявки"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Одобряет заявку на вывод."""
|
||||
|
||||
req_row = await session.execute(
|
||||
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
|
||||
{"id": payout_id},
|
||||
)
|
||||
req = req_row.fetchone()
|
||||
if not req:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Заявка не найдена или уже обработана"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
user_row = await session.execute(
|
||||
text("SELECT payout_method, card_number FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": req[1]},
|
||||
)
|
||||
user = user_row.fetchone()
|
||||
payout_method = (user[0] if user else None) or "card"
|
||||
destination = (user[1] if user else None) or None
|
||||
destination = (destination or "").strip() or None
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE payout_requests
|
||||
SET status = 'approved', method = :method, destination = :destination
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{"id": payout_id, "method": payout_method, "destination": destination},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Заявка одобрена"}, status_code=200)
|
||||
|
||||
|
||||
@router.post("/payouts/{payout_id}/reject")
|
||||
async def reject_partner_payout(
|
||||
payout_id: int = Path(..., description="ID заявки"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Отклоняет заявку на вывод и возвращает сумму на баланс."""
|
||||
|
||||
req_row = await session.execute(
|
||||
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
|
||||
{"id": payout_id},
|
||||
)
|
||||
req = req_row.fetchone()
|
||||
if not req:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Заявка не найдена или уже обработана"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
user_row = await session.execute(
|
||||
text("SELECT payout_method, card_number, partner_balance FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": req[1]},
|
||||
)
|
||||
user = user_row.fetchone()
|
||||
payout_method = (user[0] if user else None) or "card"
|
||||
destination = (user[1] if user else None) or None
|
||||
destination = (destination or "").strip() or None
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE payout_requests
|
||||
SET status = 'rejected', method = :method, destination = :destination
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{"id": payout_id, "method": payout_method, "destination": destination},
|
||||
)
|
||||
|
||||
if user is not None:
|
||||
current_balance = float(user[2] or 0.0)
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
|
||||
{"balance": current_balance + float(req[2] or 0.0), "tg_id": req[1]},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Заявка отклонена"}, status_code=200)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/percent/reset")
|
||||
async def reset_partner_percent(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Сбрасывает персональный процент партнёра к дефолту."""
|
||||
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET partner_percent = NULL, partner_percent_custom = false
|
||||
WHERE tg_id = :tg_id
|
||||
"""
|
||||
),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(content={"success": True, "message": "Процент сброшен"}, status_code=200)
|
||||
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/code")
|
||||
async def update_partner_code(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
code: str = Query(..., description="Новый код партнёра (латиница/цифры/_)"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновляет код партнёрской ссылки."""
|
||||
|
||||
raw = (code or "").strip().lower()
|
||||
if not raw:
|
||||
return JSONResponse(content={"success": False, "message": "Код не может быть пустым"}, status_code=400)
|
||||
|
||||
import re
|
||||
|
||||
if not re.fullmatch(r"[a-z0-9_]{3,32}", raw):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Неверный код. Разрешены a-z, 0-9, _ (3-32 символа)"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
exists = await session.execute(
|
||||
text("SELECT 1 FROM users WHERE partner_code = :code AND tg_id != :tg_id"),
|
||||
{"code": raw, "tg_id": tg_id},
|
||||
)
|
||||
if exists.first():
|
||||
return JSONResponse(content={"success": False, "message": "Такой код уже занят"}, status_code=409)
|
||||
|
||||
result = await session.execute(
|
||||
text("UPDATE users SET partner_code = :code WHERE tg_id = :tg_id"),
|
||||
{"code": raw, "tg_id": tg_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
return JSONResponse(content={"success": True, "message": "Код обновлён", "code": raw}, status_code=200)
|
||||
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
|
||||
|
||||
@router.post("/reset-disabled-methods")
|
||||
async def reset_disabled_payout_methods(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Сбрасывает реквизиты для отключённых способов вывода."""
|
||||
|
||||
try:
|
||||
from modules.partner_program.settings import (
|
||||
ENABLE_PAYOUT_CARD,
|
||||
ENABLE_PAYOUT_SBP,
|
||||
ENABLE_PAYOUT_TON,
|
||||
ENABLE_PAYOUT_USDT,
|
||||
)
|
||||
from modules.partner_program import buttons as B
|
||||
except Exception:
|
||||
ENABLE_PAYOUT_CARD = True
|
||||
ENABLE_PAYOUT_USDT = True
|
||||
ENABLE_PAYOUT_TON = True
|
||||
ENABLE_PAYOUT_SBP = True
|
||||
B = None
|
||||
|
||||
disabled = []
|
||||
if not ENABLE_PAYOUT_CARD and B:
|
||||
disabled.append(B.METHOD_CARD)
|
||||
if not ENABLE_PAYOUT_USDT and B:
|
||||
disabled.append(B.METHOD_USDT)
|
||||
if not ENABLE_PAYOUT_TON and B:
|
||||
disabled.append(B.METHOD_TON)
|
||||
if not ENABLE_PAYOUT_SBP and B:
|
||||
disabled.append(B.METHOD_SBP)
|
||||
|
||||
if not disabled:
|
||||
return JSONResponse(content={"success": True, "message": "Отключённых методов нет"}, status_code=200)
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET card_number = NULL
|
||||
WHERE payout_method = ANY(:methods)
|
||||
"""
|
||||
),
|
||||
{"methods": disabled},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Отключённые методы сброшены"}, status_code=200)
|
||||
|
||||
|
||||
@router.get("/{tg_id}/export")
|
||||
async def export_partner_invites_csv(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Экспортирует приглашённых партнёром в CSV."""
|
||||
|
||||
rows = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT joined_tg_id, created_at
|
||||
FROM partners
|
||||
WHERE partner_tg_id = :tg_id
|
||||
ORDER BY created_at ASC
|
||||
"""
|
||||
),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
data = rows.fetchall()
|
||||
|
||||
if not data:
|
||||
return JSONResponse(content={"success": False, "message": "Нет приглашённых"}, status_code=404)
|
||||
|
||||
buffer = StringIO()
|
||||
writer = csv.writer(buffer, delimiter=";")
|
||||
writer.writerow(["joined_tg_id", "created_at"])
|
||||
for joined_tg_id, created_at in data:
|
||||
writer.writerow([int(joined_tg_id), created_at.isoformat() if created_at else ""])
|
||||
|
||||
content = buffer.getvalue().encode("utf-8-sig")
|
||||
filename = f"partner_invites_{tg_id}.csv"
|
||||
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
@@ -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,153 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.schemas.settings import SettingResponse, SettingUpsert
|
||||
from database.models import Setting
|
||||
from database.settings import set_setting
|
||||
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
|
||||
from core.settings.modes_config import MODES_CONFIG, update_modes_config
|
||||
from core.settings.money_config import MONEY_CONFIG, update_money_config
|
||||
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
|
||||
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
|
||||
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
|
||||
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConfigUpdatePayload(BaseModel):
|
||||
value: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SettingResponse])
|
||||
async def get_all_settings(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Setting))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/configs")
|
||||
async def get_configs(admin=Depends(verify_admin_token)):
|
||||
return {
|
||||
"payments": dict(PAYMENTS_CONFIG),
|
||||
"buttons": dict(BUTTONS_CONFIG),
|
||||
"notifications": dict(NOTIFICATIONS_CONFIG),
|
||||
"modes": dict(MODES_CONFIG),
|
||||
"money": dict(MONEY_CONFIG),
|
||||
"providers_order": dict(PROVIDERS_ORDER),
|
||||
"tariffs": dict(TARIFFS_CONFIG),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/configs/{scope}")
|
||||
async def update_config_scope(
|
||||
scope: str,
|
||||
payload: ConfigUpdatePayload,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
data = dict(payload.value or {})
|
||||
normalized = scope.strip().lower().replace("-", "_")
|
||||
|
||||
if normalized == "payments":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_payments_config(session, cleaned)
|
||||
return {"payments": dict(PAYMENTS_CONFIG)}
|
||||
|
||||
if normalized == "buttons":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_buttons_config(session, cleaned)
|
||||
return {"buttons": dict(BUTTONS_CONFIG)}
|
||||
|
||||
if normalized == "notifications":
|
||||
await update_notifications_config(session, data)
|
||||
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
|
||||
|
||||
if normalized == "modes":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_modes_config(session, cleaned)
|
||||
return {"modes": dict(MODES_CONFIG)}
|
||||
|
||||
if normalized == "money":
|
||||
await update_money_config(session, data)
|
||||
return {"money": dict(MONEY_CONFIG)}
|
||||
|
||||
if normalized == "providers_order":
|
||||
cleaned: dict[str, int] = {}
|
||||
for key, value in data.items():
|
||||
try:
|
||||
cleaned[key] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await update_providers_order(session, cleaned)
|
||||
return {"providers_order": dict(PROVIDERS_ORDER)}
|
||||
|
||||
if normalized == "tariffs":
|
||||
cleaned = dict(data)
|
||||
if "ALLOW_DOWNGRADE" in cleaned:
|
||||
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
|
||||
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
|
||||
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
|
||||
if "KEY_ADDONS_PACK_MODE" in cleaned:
|
||||
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
|
||||
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
|
||||
await update_tariffs_config(session, cleaned)
|
||||
return {"tariffs": dict(TARIFFS_CONFIG)}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Unsupported config scope")
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingResponse)
|
||||
async def get_setting_by_key(
|
||||
key: str,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
return obj
|
||||
|
||||
|
||||
@router.post("/{key}", response_model=SettingResponse)
|
||||
async def upsert_setting(
|
||||
key: str,
|
||||
payload: SettingUpsert,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
obj = await set_setting(
|
||||
session=session,
|
||||
key=key,
|
||||
value=payload.value,
|
||||
description=payload.description,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=dict)
|
||||
async def delete_setting(
|
||||
key: str,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
return {"detail": "Setting deleted"}
|
||||
|
||||
|
||||
@@ -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,16 @@
|
||||
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 .settings import SettingResponse, SettingUpsert
|
||||
from .tariffs import TariffBase, TariffResponse, TariffUpdate
|
||||
from .users import UserBase, UserResponse, UserUpdate
|
||||
@@ -0,0 +1,97 @@
|
||||
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)
|
||||
|
||||
percent: int | None = Field(default=None)
|
||||
max_discount_amount: int | None = Field(default=None)
|
||||
min_order_amount: int | None = Field(default=None)
|
||||
|
||||
new_users_only: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_coupon_type(self) -> "CouponBase":
|
||||
has_amount = self.amount not in (None, 0)
|
||||
has_days = self.days is not None
|
||||
has_percent = self.percent is not None
|
||||
|
||||
provided = int(has_amount) + int(has_days) + int(has_percent)
|
||||
if provided != 1:
|
||||
raise ValueError("Coupon must have exactly one of: 'amount', 'days', 'percent'")
|
||||
|
||||
if has_days and self.days is not None and self.days <= 0:
|
||||
raise ValueError("'days' must be > 0")
|
||||
|
||||
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
|
||||
raise ValueError("'percent' must be between 1 and 100")
|
||||
|
||||
if has_percent:
|
||||
if self.min_order_amount is not None and self.min_order_amount < 0:
|
||||
raise ValueError("'min_order_amount' must be >= 0")
|
||||
if self.max_discount_amount is not None and self.max_discount_amount < 0:
|
||||
raise ValueError("'max_discount_amount' must be >= 0")
|
||||
|
||||
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)
|
||||
|
||||
percent: int | None = None
|
||||
max_discount_amount: int | None = None
|
||||
min_order_amount: int | None = None
|
||||
|
||||
new_users_only: bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_coupon_update(self) -> "CouponUpdate":
|
||||
has_amount = self.amount not in (None, 0)
|
||||
has_days = self.days is not None
|
||||
has_percent = self.percent is not None
|
||||
|
||||
provided = int(has_amount) + int(has_days) + int(has_percent)
|
||||
if provided > 1:
|
||||
raise ValueError("Specify only one of: 'amount', 'days', 'percent'")
|
||||
|
||||
if has_days and self.days is not None and self.days <= 0:
|
||||
raise ValueError("'days' must be > 0")
|
||||
|
||||
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
|
||||
raise ValueError("'percent' must be between 1 and 100")
|
||||
|
||||
if has_percent:
|
||||
if self.min_order_amount is not None and self.min_order_amount < 0:
|
||||
raise ValueError("'min_order_amount' must be >= 0")
|
||||
if self.max_discount_amount is not None and self.max_discount_amount < 0:
|
||||
raise ValueError("'max_discount_amount' must be >= 0")
|
||||
|
||||
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,97 @@
|
||||
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
|
||||
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_limit: int | None = None
|
||||
selected_price_rub: int | None = None
|
||||
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_limit: int | None = None
|
||||
|
||||
|
||||
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
|
||||
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_limit: int | None = None
|
||||
selected_price_rub: int | None = None
|
||||
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_limit: int | None = 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
|
||||
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_limit: int | None = None
|
||||
selected_price_rub: int | None = None
|
||||
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_limit: int | 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,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
@@ -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,20 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SettingUpsert(BaseModel):
|
||||
value: Any | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SettingResponse(BaseModel):
|
||||
key: str
|
||||
value: Any | None = None
|
||||
description: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
vless: bool = False
|
||||
external_squad: str | None = None
|
||||
|
||||
configurable: bool = False
|
||||
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
|
||||
device_step_rub: int | None = None
|
||||
device_overrides: dict[str, int] | None = None
|
||||
|
||||
traffic_step_rub: int | None = None
|
||||
traffic_overrides: dict[str, 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
|
||||
vless: bool | None = None
|
||||
external_squad: str | None = None
|
||||
|
||||
configurable: bool | None = None
|
||||
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
|
||||
device_step_rub: int | None = None
|
||||
device_overrides: dict[str, int] | None = None
|
||||
|
||||
traffic_step_rub: int | None = None
|
||||
traffic_overrides: dict[str, 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 | None = 0.0
|
||||
trial: int | None = 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,99 +0,0 @@
|
||||
from datetime import datetime
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Union
|
||||
|
||||
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: Union[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()
|
||||
@@ -1,76 +1,31 @@
|
||||
import traceback
|
||||
|
||||
from aiogram import Bot, Dispatcher, Router
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.types import ErrorEvent
|
||||
|
||||
from config import API_TOKEN, CRYPTO_BOT_ENABLE, FREEKASSA_ENABLE, ROBOKASSA_ENABLE, STARS_ENABLE, YOOKASSA_ENABLE
|
||||
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 config import API_TOKEN
|
||||
from filters.private import IsPrivateFilter
|
||||
from utils.button_icons import apply_button_icons_patch, set_button_icon_config
|
||||
from utils.custom_emojis import initialize_custom_emojis
|
||||
from utils.errors import setup_error_handlers
|
||||
from utils.modules_loader import load_modules_from_folder, modules_hub
|
||||
|
||||
apply_button_icons_patch()
|
||||
|
||||
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
router = Router()
|
||||
|
||||
from handlers import coupons, donate, notifications, pay, profile, start
|
||||
from handlers.admin import admin_coupons, admin_panel, admin_servers, admin_user_editor
|
||||
from handlers.instructions import instructions
|
||||
from handlers.keys import key_management, keys
|
||||
from handlers.payments import cryprobot_pay, freekassa_pay, robokassa_pay, stars_pay, yookassa_pay
|
||||
dp.include_router(modules_hub)
|
||||
|
||||
dp.include_router(admin_coupons.router)
|
||||
dp.include_router(admin_panel.router)
|
||||
dp.include_router(admin_user_editor.router)
|
||||
dp.include_router(admin_servers.router)
|
||||
dp.include_router(coupons.router)
|
||||
dp.include_router(start.router)
|
||||
dp.include_router(profile.router)
|
||||
dp.include_router(keys.router)
|
||||
dp.include_router(key_management.router)
|
||||
dp.include_router(pay.router)
|
||||
dp.include_router(notifications.router)
|
||||
dp.include_router(instructions.router)
|
||||
dp.include_router(donate.router)
|
||||
if YOOKASSA_ENABLE:
|
||||
dp.include_router(yookassa_pay.router)
|
||||
if FREEKASSA_ENABLE:
|
||||
dp.include_router(freekassa_pay.router)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
dp.include_router(cryprobot_pay.router)
|
||||
if STARS_ENABLE:
|
||||
dp.include_router(stars_pay.router)
|
||||
if ROBOKASSA_ENABLE:
|
||||
dp.include_router(robokassa_pay.router)
|
||||
load_modules_from_folder()
|
||||
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
from handlers.buttons import BUTTON_ICON_CONFIG
|
||||
|
||||
dp.message.middleware(AdminMiddleware())
|
||||
dp.callback_query.middleware(AdminMiddleware())
|
||||
set_button_icon_config(BUTTON_ICON_CONFIG)
|
||||
|
||||
dp.message.middleware(UserMiddleware())
|
||||
dp.callback_query.middleware(UserMiddleware())
|
||||
dp.message.filter(IsPrivateFilter())
|
||||
dp.callback_query.filter(IsPrivateFilter())
|
||||
|
||||
dp.message.middleware(DatabaseMiddleware())
|
||||
dp.callback_query.middleware(DatabaseMiddleware())
|
||||
|
||||
dp.message.outer_middleware(DeleteMessageMiddleware())
|
||||
dp.callback_query.outer_middleware(DeleteMessageMiddleware())
|
||||
|
||||
|
||||
@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()}"
|
||||
)
|
||||
setup_error_handlers(dp)
|
||||
initialize_custom_emojis()
|
||||
|
||||
+727
@@ -0,0 +1,727 @@
|
||||
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"
|
||||
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
|
||||
SERVICE_NAME = BOT_SERVICE
|
||||
|
||||
|
||||
def is_ascii_only(value: str) -> bool:
|
||||
"""Проверка, что строка содержит только ASCII."""
|
||||
return all(ord(ch) < 128 for ch in value)
|
||||
|
||||
|
||||
def _parse_tag_version(tag_name: str) -> tuple[int, ...]:
|
||||
"""Извлекает кортеж (major, minor, patch, ...) из тега для сортировки. v.5.1 -> (5, 1), v4 -> (4, 0)."""
|
||||
s = tag_name.strip().lstrip("v.")
|
||||
parts = []
|
||||
for part in re.split(r"[.\s]+", s):
|
||||
try:
|
||||
parts.append(int(part))
|
||||
except ValueError:
|
||||
break
|
||||
return tuple(parts) if parts else (0,)
|
||||
|
||||
|
||||
def warn_english_only():
|
||||
"""Предупреждение о необходимости английской раскладки."""
|
||||
console.print("[red]Обнаружен ввод с неанглийской раскладкой.[/red]")
|
||||
console.print("[yellow]Пожалуйста, переключите раскладку на ENG и введите снова.[/yellow]")
|
||||
|
||||
|
||||
def safe_confirm(message: str, **kwargs) -> bool:
|
||||
"""Безопасный Confirm.ask с защитой от русской раскладки."""
|
||||
while True:
|
||||
try:
|
||||
result = Confirm.ask(message, **kwargs)
|
||||
return result
|
||||
except UnicodeDecodeError:
|
||||
warn_english_only()
|
||||
|
||||
|
||||
def safe_prompt(message: str, **kwargs) -> str:
|
||||
"""Безопасный Prompt.ask с защитой от русской раскладки."""
|
||||
while True:
|
||||
try:
|
||||
value = Prompt.ask(message, **kwargs)
|
||||
except UnicodeDecodeError:
|
||||
warn_english_only()
|
||||
continue
|
||||
if isinstance(value, str) and not is_ascii_only(value):
|
||||
warn_english_only()
|
||||
continue
|
||||
return value
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 = safe_prompt(
|
||||
"[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 safe_confirm("[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(
|
||||
["rsync", "-a", "--delete", f"{sel_path}/", f"{PROJECT_DIR}/"],
|
||||
check=False,
|
||||
).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, 0o644)
|
||||
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([python312_path, "-m", "venv", "venv"], check=True)
|
||||
|
||||
progress.update(task_id, description="Установка зависимостей...")
|
||||
subprocess.run(
|
||||
[os.path.join("venv", "bin", "pip"), "install", "-r", "requirements.txt"],
|
||||
check=True,
|
||||
cwd=PROJECT_DIR,
|
||||
)
|
||||
|
||||
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(["sudo", "systemctl", "restart", SERVICE_NAME])
|
||||
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")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold red]Обновление на DEV / BETA-ветку[/bold red]\n\n"
|
||||
"[white]"
|
||||
"• Dev-ветка может содержать изменения, которые ещё находятся в доработке.\n"
|
||||
"• Возможны ошибки и непредсказуемое поведение отдельных функций, особенно режима стран.\n\n"
|
||||
"• BETA-версии бота в первую очередь ориентированы на опытных пользователей, "
|
||||
"готовых протестировать новые возможности и осознанно работать с обновлённым функционалом.\n"
|
||||
"[/white]\n\n"
|
||||
"[yellow]Перед началом обновления CLI автоматически создаёт резервную копию проекта, "
|
||||
"что позволит при необходимости безопасно восстановиться из бэкапа.[/yellow]",
|
||||
border_style="red",
|
||||
title="[bold red]Нестабильная ветка разработки[/bold red]",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
|
||||
if local_version and remote_version:
|
||||
console.print(f"[cyan]Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
|
||||
if local_version == remote_version:
|
||||
if not safe_confirm("[yellow]Версия актуальна. Обновить всё равно?[/yellow]"):
|
||||
return
|
||||
|
||||
if not safe_confirm(
|
||||
"[bold red]Продолжить обновление на dev-ветку с учётом возможных особенностей работы?[/bold red]"
|
||||
):
|
||||
return
|
||||
|
||||
console.print("[red]ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
|
||||
if not safe_confirm("[red]Продолжить обновление?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = safe_confirm("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_img = safe_confirm("[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 "
|
||||
|
||||
rsync_cmd = ["rsync", "-a"] + [x for x in exclude_options.split() if x] + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
|
||||
subprocess.run(rsync_cmd)
|
||||
|
||||
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 _do_update_to_tag(tag_name: str, update_buttons: bool, update_img: bool) -> None:
|
||||
"""Общая логика обновления до указанного тега (релиз или произвольный тег)."""
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
subprocess.run(
|
||||
["git", "clone", "--branch", tag_name, "--depth", "1", GITHUB_REPO, TEMP_DIR],
|
||||
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 "
|
||||
|
||||
rsync_cmd = ["rsync", "-a"] + exclude_options.split() + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
|
||||
subprocess.run(rsync_cmd)
|
||||
|
||||
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]")
|
||||
|
||||
|
||||
def update_from_release():
|
||||
if not safe_confirm("[yellow]Подтвердите обновление Solobot до релиза или патча[/yellow]"):
|
||||
return
|
||||
|
||||
console.print("[red]ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
|
||||
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
|
||||
if not safe_confirm("[red]Вы точно хотите продолжить?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = safe_confirm("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
update_img = safe_confirm("[yellow]Обновлять папку img?[/yellow]", default=False)
|
||||
|
||||
backup_project()
|
||||
install_git_if_needed()
|
||||
install_rsync_if_needed()
|
||||
|
||||
try:
|
||||
rel_resp = requests.get(
|
||||
"https://api.github.com/repos/Vladless/Solo_bot/releases",
|
||||
timeout=10,
|
||||
)
|
||||
releases = rel_resp.json() if rel_resp.status_code == 200 else []
|
||||
release_tag_names = {r["tag_name"] for r in releases}
|
||||
|
||||
tags_resp = requests.get(
|
||||
"https://api.github.com/repos/Vladless/Solo_bot/tags",
|
||||
params={"per_page": 50},
|
||||
timeout=10,
|
||||
)
|
||||
if tags_resp.status_code != 200:
|
||||
raise ValueError("Не удалось получить список тегов")
|
||||
tags_data = tags_resp.json()
|
||||
all_tag_names = [t["name"] for t in tags_data]
|
||||
|
||||
tag_names = [name for name in all_tag_names if _parse_tag_version(name)[0] >= 4]
|
||||
tag_names.sort(key=_parse_tag_version)
|
||||
|
||||
if not tag_names:
|
||||
raise ValueError("Нет доступных тегов (ожидаются версии начиная с 4)")
|
||||
|
||||
console.print("\n[bold green]Релизы и патчи:[/bold green]")
|
||||
for idx, name in enumerate(tag_names, 1):
|
||||
label = " [dim](релиз)[/dim]" if name in release_tag_names else " [dim](патч)[/dim]"
|
||||
console.print(f"[cyan]{idx}.[/cyan] {name}{label}")
|
||||
|
||||
choices = [str(i) for i in range(1, len(tag_names) + 1)]
|
||||
selected = safe_prompt(
|
||||
"[bold blue]Выберите номер версии[/bold blue]",
|
||||
choices=choices,
|
||||
)
|
||||
tag_name = tag_names[int(selected) - 1]
|
||||
|
||||
if not safe_confirm(f"[yellow]Установить {tag_name}?[/yellow]"):
|
||||
return
|
||||
|
||||
console.print(f"[cyan]Клонируем {tag_name} во временную папку...[/cyan]")
|
||||
_do_update_to_tag(tag_name, update_buttons, update_img)
|
||||
|
||||
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 = safe_prompt("[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.4.0", title_style="bold magenta", header_style="bold blue")
|
||||
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||
table.add_column("Операция", style="white")
|
||||
table.add_row("1", "Запустить бота (systemd)")
|
||||
table.add_row("2", "Запустить напрямую: venv/bin/python main.py")
|
||||
table.add_row("3", "Перезапустить бота (systemd)")
|
||||
table.add_row("4", "Остановить бота (systemd)")
|
||||
table.add_row("5", "Показать логи (80 строк)")
|
||||
table.add_row("6", "Показать статус")
|
||||
table.add_row("7", "Обновить Solobot")
|
||||
table.add_row("8", "Восстановить из бэкапа")
|
||||
table.add_row("9", "Выход")
|
||||
console.print(table)
|
||||
|
||||
|
||||
def main():
|
||||
os.chdir(PROJECT_DIR)
|
||||
auto_update_cli()
|
||||
print_logo()
|
||||
try:
|
||||
while True:
|
||||
show_menu()
|
||||
choice = safe_prompt(
|
||||
"[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 safe_confirm("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
|
||||
subprocess.run(["venv/bin/python", "main.py"])
|
||||
elif choice == "3":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
if safe_confirm("[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 safe_confirm("[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,108 +0,0 @@
|
||||
import py3xui
|
||||
|
||||
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 = 1
|
||||
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.
@@ -0,0 +1,27 @@
|
||||
from database import async_session_maker
|
||||
from database.db import warm_pool
|
||||
from database.tariffs import initialize_all_tariff_weights
|
||||
|
||||
from .settings.buttons_config import BUTTONS_CONFIG, load_buttons_config, update_buttons_config
|
||||
from .settings.management_config import MANAGEMENT_CONFIG, load_management_config, update_management_config
|
||||
from .settings.modes_config import MODES_CONFIG, load_modes_config, update_modes_config
|
||||
from .settings.money_config import MONEY_CONFIG, load_money_config, update_money_config
|
||||
from .settings.notifications_config import NOTIFICATIONS_CONFIG, load_notifications_config, update_notifications_config
|
||||
from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, update_payments_config
|
||||
from .settings.providers_order_config import PROVIDERS_ORDER, load_providers_order, update_providers_order
|
||||
from .settings.tariffs_config import TARIFFS_CONFIG, load_tariffs_config, update_tariffs_config
|
||||
|
||||
|
||||
async def bootstrap() -> None:
|
||||
await warm_pool()
|
||||
async with async_session_maker() as session:
|
||||
await initialize_all_tariff_weights(session)
|
||||
await load_buttons_config(session)
|
||||
await load_notifications_config(session)
|
||||
await load_modes_config(session)
|
||||
await load_payments_config(session)
|
||||
await load_providers_order(session)
|
||||
await load_money_config(session)
|
||||
await load_management_config(session)
|
||||
await load_tariffs_config(session)
|
||||
await session.commit()
|
||||
@@ -0,0 +1 @@
|
||||
PAYMENT_SYSTEMS_EXCLUDED = ("referral", "coupon", "cashback", "admin")
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,65 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_BUTTONS_CONFIG
|
||||
|
||||
|
||||
BUTTONS_CONFIG: dict[str, bool] = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
BUTTONS_CONFIG.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
BUTTONS_CONFIG.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
|
||||
|
||||
async def load_buttons_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "BUTTONS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
setting = Setting(
|
||||
key="BUTTONS_CONFIG",
|
||||
value=buttons_config,
|
||||
description="Конфигурация кнопок бота",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.update(stored)
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
setting.value = buttons_config
|
||||
|
||||
BUTTONS_CONFIG.clear()
|
||||
BUTTONS_CONFIG.update(buttons_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_buttons_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "BUTTONS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="BUTTONS_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация кнопок бота",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.update(new_values)
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
|
||||
BUTTONS_CONFIG.clear()
|
||||
BUTTONS_CONFIG.update(buttons_config)
|
||||
@@ -0,0 +1,60 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_MANAGEMENT_CONFIG
|
||||
|
||||
|
||||
MANAGEMENT_CONFIG: dict[str, Any] = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
MANAGEMENT_SETTING_KEY = "MANAGEGENT_CONFIG"
|
||||
|
||||
|
||||
async def load_management_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key=MANAGEMENT_SETTING_KEY,
|
||||
value=management_config,
|
||||
description="Конфигурация управления ботом",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
management_config.update(stored)
|
||||
setting.value = management_config
|
||||
|
||||
MANAGEMENT_CONFIG.clear()
|
||||
MANAGEMENT_CONFIG.update(management_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_management_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key=MANAGEMENT_SETTING_KEY,
|
||||
value=new_values,
|
||||
description="Конфигурация управления ботом",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
management_config.update(new_values)
|
||||
|
||||
MANAGEMENT_CONFIG.clear()
|
||||
MANAGEMENT_CONFIG.update(management_config)
|
||||
@@ -0,0 +1,57 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_MODES_CONFIG
|
||||
|
||||
|
||||
MODES_CONFIG: dict[str, bool] = DEFAULT_MODES_CONFIG.copy()
|
||||
|
||||
|
||||
async def load_modes_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "MODES_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
modes_config = DEFAULT_MODES_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="MODES_CONFIG",
|
||||
value=modes_config,
|
||||
description="Конфигурация режимов работы бота",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
modes_config = DEFAULT_MODES_CONFIG.copy()
|
||||
modes_config.update(stored)
|
||||
setting.value = modes_config
|
||||
|
||||
MODES_CONFIG.clear()
|
||||
MODES_CONFIG.update(modes_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_modes_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "MODES_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="MODES_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация режимов работы бота",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
modes_config = DEFAULT_MODES_CONFIG.copy()
|
||||
modes_config.update(new_values)
|
||||
|
||||
MODES_CONFIG.clear()
|
||||
MODES_CONFIG.update(modes_config)
|
||||
@@ -0,0 +1,75 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_MONEY_CONFIG
|
||||
|
||||
|
||||
MONEY_CONFIG: dict[str, Any] = DEFAULT_MONEY_CONFIG.copy()
|
||||
|
||||
|
||||
def get_currency_mode() -> tuple[str, bool]:
|
||||
mode_cfg = MONEY_CONFIG.get("CURRENCY_MODE", "RUB")
|
||||
raw = str(mode_cfg or "RUB").upper()
|
||||
|
||||
if raw not in ("RUB", "USD", "RUB+USD", "RUB+USD_ONE_SCREEN"):
|
||||
raw = "RUB"
|
||||
|
||||
one_screen = raw == "RUB+USD_ONE_SCREEN"
|
||||
if raw in ("RUB+USD", "RUB+USD_ONE_SCREEN"):
|
||||
base_mode = "RUB+USD"
|
||||
else:
|
||||
base_mode = raw
|
||||
|
||||
return base_mode, one_screen
|
||||
|
||||
|
||||
async def load_money_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "MONEY_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
money_config = DEFAULT_MONEY_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="MONEY_CONFIG",
|
||||
value=money_config,
|
||||
description="Конфигурация валютных настроек",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
money_config = DEFAULT_MONEY_CONFIG.copy()
|
||||
money_config.update(stored)
|
||||
setting.value = money_config
|
||||
|
||||
MONEY_CONFIG.clear()
|
||||
MONEY_CONFIG.update(money_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_money_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "MONEY_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="MONEY_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация валютных настроек",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
money_config = DEFAULT_MONEY_CONFIG.copy()
|
||||
money_config.update(new_values)
|
||||
|
||||
MONEY_CONFIG.clear()
|
||||
MONEY_CONFIG.update(money_config)
|
||||
@@ -0,0 +1,59 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG
|
||||
|
||||
|
||||
NOTIFICATIONS_CONFIG: dict[str, Any] = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||
|
||||
|
||||
async def load_notifications_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "NOTIFICATIONS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="NOTIFICATIONS_CONFIG",
|
||||
value=notifications_config,
|
||||
description="Конфигурация уведомлений",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||
notifications_config.update(stored)
|
||||
setting.value = notifications_config
|
||||
|
||||
NOTIFICATIONS_CONFIG.clear()
|
||||
NOTIFICATIONS_CONFIG.update(notifications_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_notifications_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "NOTIFICATIONS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="NOTIFICATIONS_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация уведомлений",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
|
||||
notifications_config.update(new_values)
|
||||
|
||||
NOTIFICATIONS_CONFIG.clear()
|
||||
NOTIFICATIONS_CONFIG.update(notifications_config)
|
||||
@@ -0,0 +1,57 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from ..defaults import DEFAULT_PAYMENTS_CONFIG
|
||||
|
||||
|
||||
PAYMENTS_CONFIG: dict[str, bool] = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||
|
||||
|
||||
async def load_payments_config(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "PAYMENTS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="PAYMENTS_CONFIG",
|
||||
value=payments_config,
|
||||
description="Конфигурация платёжных провайдеров",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||
payments_config.update(stored)
|
||||
setting.value = payments_config
|
||||
|
||||
PAYMENTS_CONFIG.clear()
|
||||
PAYMENTS_CONFIG.update(payments_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_payments_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "PAYMENTS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="PAYMENTS_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация платёжных провайдеров",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
|
||||
payments_config.update(new_values)
|
||||
|
||||
PAYMENTS_CONFIG.clear()
|
||||
PAYMENTS_CONFIG.update(payments_config)
|
||||
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
PROVIDERS_ORDER: dict[str, int] = {}
|
||||
|
||||
|
||||
async def load_providers_order(session: AsyncSession) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "PROVIDERS_ORDER")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
PROVIDERS_ORDER.clear()
|
||||
if setting and isinstance(setting.value, dict):
|
||||
PROVIDERS_ORDER.update({k: int(v) for k, v in setting.value.items()})
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_providers_order(session: AsyncSession, new_order: dict[str, int]) -> None:
|
||||
stmt = select(Setting).where(Setting.key == "PROVIDERS_ORDER")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="PROVIDERS_ORDER",
|
||||
value=new_order,
|
||||
description="Порядок отображения платёжных провайдеров",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_order
|
||||
|
||||
await session.commit()
|
||||
|
||||
PROVIDERS_ORDER.clear()
|
||||
PROVIDERS_ORDER.update(new_order)
|
||||
@@ -0,0 +1,193 @@
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
|
||||
TARIFFS_CONFIG: dict[str, Any] = {
|
||||
"ALLOW_DOWNGRADE": True,
|
||||
"KEY_ADDONS_PACK_MODE": "all",
|
||||
"KEY_ADDONS_PRICE_BASE_MODE": "current",
|
||||
}
|
||||
|
||||
|
||||
async def load_tariffs_config(session: AsyncSession) -> None:
|
||||
"""Загружает конфиг тарифов из БД."""
|
||||
stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
tariffs_config = TARIFFS_CONFIG.copy()
|
||||
setting = Setting(
|
||||
key="TARIFFS_CONFIG",
|
||||
value=tariffs_config,
|
||||
description="Конфигурация тарифов",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
stored = setting.value or {}
|
||||
tariffs_config = TARIFFS_CONFIG.copy()
|
||||
tariffs_config.update(stored)
|
||||
setting.value = tariffs_config
|
||||
|
||||
TARIFFS_CONFIG.clear()
|
||||
TARIFFS_CONFIG.update(tariffs_config)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def update_tariffs_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
|
||||
"""Обновляет конфиг тарифов."""
|
||||
stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(
|
||||
key="TARIFFS_CONFIG",
|
||||
value=new_values,
|
||||
description="Конфигурация тарифов",
|
||||
)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = new_values
|
||||
|
||||
await session.commit()
|
||||
|
||||
tariffs_config = TARIFFS_CONFIG.copy()
|
||||
tariffs_config.update(new_values)
|
||||
|
||||
TARIFFS_CONFIG.clear()
|
||||
TARIFFS_CONFIG.update(tariffs_config)
|
||||
|
||||
|
||||
def calc_extra_devices_price(tariff: dict[str, Any], device_limit: int) -> int:
|
||||
base_device_limit = int(tariff.get("device_limit") or 1)
|
||||
extra_devices = max(0, device_limit - base_device_limit)
|
||||
if extra_devices <= 0:
|
||||
return 0
|
||||
|
||||
step_price = int(tariff.get("device_step_rub") or 0)
|
||||
overrides = tariff.get("device_overrides") or {}
|
||||
|
||||
override_total = overrides.get(str(device_limit))
|
||||
if override_total is not None:
|
||||
return int(ceil(float(override_total)))
|
||||
|
||||
return int(ceil(extra_devices * step_price))
|
||||
|
||||
|
||||
def calc_extra_traffic_price(tariff: dict[str, Any], traffic_gb: int | None) -> int:
|
||||
if traffic_gb is None:
|
||||
return 0
|
||||
|
||||
traffic_limit_bytes = tariff.get("traffic_limit")
|
||||
if traffic_limit_bytes:
|
||||
base_traffic_gb = ceil(traffic_limit_bytes / 1024 / 1024 / 1024)
|
||||
else:
|
||||
base_traffic_gb = 0
|
||||
|
||||
step_price = int(tariff.get("traffic_step_rub") or 0)
|
||||
overrides = tariff.get("traffic_overrides") or {}
|
||||
|
||||
override_total = overrides.get(str(traffic_gb))
|
||||
if override_total is not None:
|
||||
return int(ceil(float(override_total)))
|
||||
|
||||
if traffic_gb == 0:
|
||||
return 0
|
||||
|
||||
extra_gb = max(0, traffic_gb - base_traffic_gb)
|
||||
if extra_gb <= 0:
|
||||
return 0
|
||||
|
||||
return int(ceil(extra_gb * step_price))
|
||||
|
||||
|
||||
def calculate_config_price(
|
||||
tariff: dict[str, Any],
|
||||
duration_days: int,
|
||||
device_limit: int,
|
||||
traffic_gb: int | None,
|
||||
) -> int:
|
||||
base_duration = int(tariff.get("duration_days") or 0) or duration_days or 30
|
||||
if base_duration <= 0:
|
||||
base_duration = duration_days or 30
|
||||
|
||||
base_price = int(tariff.get("price_rub") or 0)
|
||||
|
||||
duration_multiplier = duration_days / base_duration
|
||||
base_price_scaled = base_price * duration_multiplier
|
||||
|
||||
extra_devices_price = calc_extra_devices_price(tariff, device_limit)
|
||||
extra_traffic_price = calc_extra_traffic_price(tariff, traffic_gb)
|
||||
|
||||
total = base_price_scaled + extra_devices_price + extra_traffic_price
|
||||
return int(ceil(total))
|
||||
|
||||
|
||||
def normalize_tariff_config(tariff: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_duration_options = tariff.get("duration_options") or []
|
||||
duration_options: list[int] = []
|
||||
for value in raw_duration_options:
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v > 0:
|
||||
duration_options.append(v)
|
||||
if not duration_options:
|
||||
base_duration = int(tariff.get("duration_days") or 0) or 30
|
||||
duration_options = [base_duration]
|
||||
duration_options = sorted(set(duration_options))
|
||||
|
||||
raw_device_options = tariff.get("device_options") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v > 0:
|
||||
device_options.append(v)
|
||||
if not device_options:
|
||||
base_device_limit = int(tariff.get("device_limit") or 0)
|
||||
if base_device_limit > 0:
|
||||
device_options = [base_device_limit]
|
||||
else:
|
||||
device_options = []
|
||||
device_options = sorted(set(device_options))
|
||||
|
||||
raw_traffic_options = tariff.get("traffic_options_gb")
|
||||
traffic_options_gb: list[int] | None
|
||||
if raw_traffic_options is None:
|
||||
traffic_options_gb = None
|
||||
else:
|
||||
traffic_values: list[int] = []
|
||||
has_unlimited = False
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v == 0:
|
||||
has_unlimited = True
|
||||
elif v > 0:
|
||||
traffic_values.append(v)
|
||||
if not traffic_values and not has_unlimited:
|
||||
traffic_options_gb = None
|
||||
else:
|
||||
unique_values = sorted(set(traffic_values))
|
||||
if has_unlimited:
|
||||
traffic_options_gb = [0] + unique_values
|
||||
else:
|
||||
traffic_options_gb = unique_values
|
||||
|
||||
return {
|
||||
"duration_options": duration_options,
|
||||
"device_options": device_options,
|
||||
"traffic_options_gb": traffic_options_gb,
|
||||
}
|
||||
-1229
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,160 @@
|
||||
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 | None,
|
||||
usage_limit: int,
|
||||
days: int | None = None,
|
||||
new_users_only: bool = False,
|
||||
percent: int | None = None,
|
||||
max_discount_amount: int | None = None,
|
||||
min_order_amount: int | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
|
||||
if exists:
|
||||
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
|
||||
return False
|
||||
|
||||
if percent is not None:
|
||||
try:
|
||||
percent_value = int(percent)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(f"[Coupon] ⚠️ Некорректный процент для купона {code}.")
|
||||
return False
|
||||
|
||||
if percent_value <= 0 or percent_value > 100:
|
||||
logger.warning(f"[Coupon] ⚠️ процент должен быть в диапазоне 1..100 для купона {code}.")
|
||||
return False
|
||||
|
||||
if (amount or 0) > 0 or (days or 0) > 0:
|
||||
logger.warning(f"[Coupon] ⚠️ Купон {code} не может одновременно иметь percent и amount/days.")
|
||||
return False
|
||||
|
||||
await session.execute(
|
||||
insert(Coupon).values(
|
||||
code=code,
|
||||
amount=int(amount) if amount is not None else 0,
|
||||
usage_limit=usage_limit,
|
||||
usage_count=0,
|
||||
is_used=False,
|
||||
days=days,
|
||||
new_users_only=new_users_only,
|
||||
percent=percent,
|
||||
max_discount_amount=max_discount_amount,
|
||||
min_order_amount=min_order_amount,
|
||||
)
|
||||
)
|
||||
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()
|
||||
raise
|
||||
|
||||
|
||||
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()
|
||||
raise
|
||||
|
||||
|
||||
def apply_percent_coupon(price_rub: int, coupon: Coupon) -> tuple[int, int]:
|
||||
percent = coupon.percent
|
||||
if percent is None:
|
||||
return price_rub, 0
|
||||
|
||||
if coupon.min_order_amount is not None and price_rub < int(coupon.min_order_amount):
|
||||
return price_rub, 0
|
||||
|
||||
discount = (price_rub * int(percent)) // 100
|
||||
|
||||
if coupon.max_discount_amount is not None:
|
||||
discount = min(discount, int(coupon.max_discount_amount))
|
||||
|
||||
final_price = price_rub - discount
|
||||
if final_price < 0:
|
||||
final_price = 0
|
||||
|
||||
return final_price, discount
|
||||
@@ -0,0 +1,50 @@
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
from config import DATABASE_URL, DB_MAX_OVERFLOW, DB_POOL_SIZE
|
||||
|
||||
|
||||
CONCURRENT_UPDATES_LIMIT = DB_POOL_SIZE + DB_MAX_OVERFLOW
|
||||
MAX_UPDATE_AGE_SEC = 28
|
||||
CONCURRENT_UPDATES_WAIT_TIMEOUT_SEC = 8
|
||||
CONCURRENT_UPDATES_GATE_LIMIT = 150
|
||||
CONCURRENT_UPDATES_GATE_WAIT_SEC = 2
|
||||
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=False,
|
||||
future=True,
|
||||
pool_size=DB_POOL_SIZE,
|
||||
max_overflow=DB_MAX_OVERFLOW,
|
||||
pool_timeout=60,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300,
|
||||
)
|
||||
|
||||
async_session_maker = async_sessionmaker(
|
||||
bind=engine,
|
||||
expire_on_commit=False,
|
||||
class_=AsyncSession,
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
WARM_POOL_COUNT = 10
|
||||
|
||||
|
||||
async def warm_pool() -> None:
|
||||
"""
|
||||
Прогревает пул соединений при старте.
|
||||
"""
|
||||
|
||||
async def _one() -> None:
|
||||
async with async_session_maker() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
|
||||
count = min(WARM_POOL_COUNT, DB_POOL_SIZE)
|
||||
if count <= 0:
|
||||
return
|
||||
await asyncio.gather(*[asyncio.create_task(_one()) for _ in range(count)])
|
||||
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_gb: int | None = None,
|
||||
selected_price_rub: 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,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_gb=selected_traffic_gb,
|
||||
selected_price_rub=selected_price_rub,
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"🎁 Подарок {gift_id} сохранён "
|
||||
f"(tariff_id={tariff_id}, max_usages={max_usages}, "
|
||||
f"device={selected_device_limit}, traffic={selected_traffic_gb}, price={selected_price_rub})"
|
||||
)
|
||||
return True
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
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_(PAYMENT_SYSTEMS_EXCLUDED))
|
||||
.where(~Payment.tg_id.in_(sub_active))
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,128 @@
|
||||
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 core.bootstrap import MODES_CONFIG
|
||||
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
|
||||
|
||||
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||
|
||||
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 as e:
|
||||
await session.rollback()
|
||||
raise RuntimeError(f"Ошибка при импорте пользователя tg_id={tg_id}") from e
|
||||
|
||||
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 as e:
|
||||
await session.rollback()
|
||||
raise RuntimeError(f"Ошибка при импорте ключа client_id={client_id}") from e
|
||||
|
||||
await session.commit()
|
||||
return imported, skipped
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,289 @@
|
||||
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 = None,
|
||||
alias: str | None = None,
|
||||
selected_device_limit: int | None = None,
|
||||
selected_traffic_limit: int | None = None,
|
||||
selected_price_rub: int | None = None,
|
||||
current_device_limit: int | None = None,
|
||||
current_traffic_limit: int | None = 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:
|
||||
values: dict = {
|
||||
"email": email,
|
||||
"expiry_time": expiry_time,
|
||||
"key": key,
|
||||
"server_id": server_id,
|
||||
"remnawave_link": remnawave_link,
|
||||
"tariff_id": tariff_id,
|
||||
"alias": alias,
|
||||
}
|
||||
|
||||
if selected_device_limit is not None:
|
||||
values["selected_device_limit"] = selected_device_limit
|
||||
if selected_traffic_limit is not None:
|
||||
values["selected_traffic_limit"] = selected_traffic_limit
|
||||
if selected_price_rub is not None:
|
||||
values["selected_price_rub"] = selected_price_rub
|
||||
if current_device_limit is not None:
|
||||
values["current_device_limit"] = current_device_limit
|
||||
if current_traffic_limit is not None:
|
||||
values["current_traffic_limit"] = current_traffic_limit
|
||||
|
||||
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(**values))
|
||||
logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
|
||||
else:
|
||||
if current_device_limit is None:
|
||||
current_device_limit = selected_device_limit
|
||||
if current_traffic_limit is None:
|
||||
current_traffic_limit = selected_traffic_limit
|
||||
|
||||
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,
|
||||
selected_device_limit=selected_device_limit,
|
||||
selected_traffic_limit=selected_traffic_limit,
|
||||
selected_price_rub=selected_price_rub,
|
||||
current_device_limit=current_device_limit,
|
||||
current_traffic_limit=current_traffic_limit,
|
||||
)
|
||||
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()
|
||||
raise
|
||||
|
||||
|
||||
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:
|
||||
"""Возвращает подробную информацию о ключе по email."""
|
||||
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,
|
||||
"selected_device_limit": key.selected_device_limit,
|
||||
"selected_traffic_limit": key.selected_traffic_limit,
|
||||
"selected_price_rub": key.selected_price_rub,
|
||||
"current_device_limit": key.current_device_limit,
|
||||
"current_traffic_limit": key.current_traffic_limit,
|
||||
}
|
||||
|
||||
|
||||
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, commit: bool = True):
|
||||
stmt = delete(Key).where(Key.tg_id == identifier if str(identifier).isdigit() else Key.client_id == identifier)
|
||||
await session.execute(stmt)
|
||||
if commit:
|
||||
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
|
||||
|
||||
|
||||
async def save_key_config_with_mode(
|
||||
session: AsyncSession,
|
||||
email: str,
|
||||
selected_devices: int | None,
|
||||
selected_traffic_gb: int | None,
|
||||
total_price: int,
|
||||
has_device_choice: bool,
|
||||
has_traffic_choice: bool,
|
||||
config_mode: str,
|
||||
) -> None:
|
||||
values: dict = {}
|
||||
|
||||
if config_mode == "pack":
|
||||
if has_device_choice and selected_devices is not None:
|
||||
values["current_device_limit"] = int(selected_devices)
|
||||
if has_traffic_choice and selected_traffic_gb is not None:
|
||||
values["current_traffic_limit"] = int(selected_traffic_gb)
|
||||
else:
|
||||
device_val = int(selected_devices) if selected_devices is not None and has_device_choice else None
|
||||
traffic_val = int(selected_traffic_gb) if selected_traffic_gb is not None and has_traffic_choice else None
|
||||
|
||||
values["selected_device_limit"] = device_val
|
||||
values["selected_traffic_limit"] = traffic_val
|
||||
values["selected_price_rub"] = int(total_price)
|
||||
values["current_device_limit"] = device_val
|
||||
values["current_traffic_limit"] = traffic_val
|
||||
|
||||
if not values:
|
||||
return
|
||||
|
||||
await session.execute(update(Key).where(Key.email == email).values(**values))
|
||||
|
||||
|
||||
async def reset_key_current_limits_to_selected(session: AsyncSession, client_id: str):
|
||||
"""Сбрасывает текущие лимиты к выбранным для ключа."""
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET current_device_limit = selected_device_limit,
|
||||
current_traffic_limit = selected_traffic_limit
|
||||
WHERE client_id = :client_id
|
||||
"""
|
||||
),
|
||||
{"client_id": client_id},
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"Текущие лимиты ключа {client_id} сброшены к выбранным")
|
||||
@@ -0,0 +1,303 @@
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
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)
|
||||
|
||||
selected_device_limit = Column(Integer, nullable=True)
|
||||
selected_traffic_limit = Column(BigInteger, nullable=True)
|
||||
selected_price_rub = Column(Integer, nullable=True)
|
||||
|
||||
current_device_limit = Column(Integer, nullable=True)
|
||||
current_traffic_limit = Column(BigInteger, nullable=True)
|
||||
|
||||
|
||||
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)
|
||||
external_squad: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
configurable = Column(Boolean, nullable=False, server_default="false")
|
||||
|
||||
device_options = Column(JSONB, nullable=True)
|
||||
traffic_options_gb = Column(JSONB, nullable=True)
|
||||
|
||||
device_step_rub = Column(Integer, nullable=True)
|
||||
device_overrides = Column(JSONB, nullable=True)
|
||||
|
||||
traffic_step_rub = Column(Integer, nullable=True)
|
||||
traffic_overrides = Column(JSONB, nullable=True)
|
||||
|
||||
|
||||
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)
|
||||
new_users_only = Column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
percent = Column(Integer, nullable=True)
|
||||
max_discount_amount = Column(Integer, nullable=True)
|
||||
min_order_amount = 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"))
|
||||
|
||||
selected_device_limit = Column(Integer, nullable=True)
|
||||
selected_traffic_gb = Column(Integer, nullable=True)
|
||||
selected_price_rub = Column(Integer, nullable=True)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class Setting(DictLikeMixin, Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key = Column(String, primary_key=True)
|
||||
value = Column(JSONB, nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -0,0 +1,179 @@
|
||||
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 core.bootstrap import NOTIFICATIONS_CONFIG
|
||||
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()
|
||||
raise
|
||||
|
||||
|
||||
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
|
||||
|
||||
hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
|
||||
|
||||
expires_at = last_time + timedelta(hours=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}")
|
||||
await session.rollback()
|
||||
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}")
|
||||
await session.rollback()
|
||||
return []
|
||||
@@ -0,0 +1,214 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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()
|
||||
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 not payment:
|
||||
return None
|
||||
return {
|
||||
"id": payment.id,
|
||||
"tg_id": payment.tg_id,
|
||||
"amount": payment.amount,
|
||||
"currency": payment.currency,
|
||||
"status": payment.status,
|
||||
"payment_system": payment.payment_system,
|
||||
"payment_id": payment.payment_id,
|
||||
"created_at": payment.created_at,
|
||||
"metadata": payment.metadata_,
|
||||
"original_amount": payment.original_amount,
|
||||
}
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при поиске платежа id={internal_id}: {e}")
|
||||
await session.rollback()
|
||||
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 not payment:
|
||||
return None
|
||||
return {
|
||||
"id": payment.id,
|
||||
"tg_id": payment.tg_id,
|
||||
"amount": payment.amount,
|
||||
"currency": payment.currency,
|
||||
"status": payment.status,
|
||||
"payment_system": payment.payment_system,
|
||||
"payment_id": payment.payment_id,
|
||||
"created_at": payment.created_at,
|
||||
"metadata": payment.metadata_,
|
||||
"original_amount": payment.original_amount,
|
||||
}
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при поиске платежа payment_id={pid}: {e}")
|
||||
await session.rollback()
|
||||
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")
|
||||
)
|
||||
res = await session.execute(stmt)
|
||||
await session.commit()
|
||||
affected = res.rowcount or 0
|
||||
return affected
|
||||
|
||||
|
||||
async def get_all_payments(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
statuses: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
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())
|
||||
|
||||
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
|
||||
]
|
||||
@@ -0,0 +1,257 @@
|
||||
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 core.bootstrap import BUTTONS_CONFIG
|
||||
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:
|
||||
referral_enabled = bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", True))
|
||||
if not referral_enabled:
|
||||
logger.debug("Реферальная программа отключена, бонусы не начисляются")
|
||||
return 0.0
|
||||
|
||||
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'
|
||||
AND payment_system NOT IN ('coupon', 'admin', 'referral')
|
||||
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 p.payment_system NOT IN ('coupon', 'admin', 'referral')
|
||||
AND rl.level <= :max_levels
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
text(bonus_query), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||
{"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), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||
{"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}")
|
||||
await session.rollback()
|
||||
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,406 @@
|
||||
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 = {}
|
||||
tariffs_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():
|
||||
if sg and sg.isdigit():
|
||||
tariffs_map.setdefault(sid, []).append(int(sg))
|
||||
else:
|
||||
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, []),
|
||||
"tariff_ids": tariffs_map.get(s.id, []),
|
||||
"special_groups": special,
|
||||
"cluster_name": cluster,
|
||||
"server_id": s.id,
|
||||
})
|
||||
return grouped
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Ошибка при получении серверов: {e}")
|
||||
await session.rollback()
|
||||
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}")
|
||||
await session.rollback()
|
||||
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}")
|
||||
await session.rollback()
|
||||
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}")
|
||||
await session.rollback()
|
||||
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,
|
||||
tariff_id: int | None = None,
|
||||
) -> list:
|
||||
names = [s.get("server_name") for s in cluster if s.get("server_name")]
|
||||
if not names:
|
||||
return []
|
||||
|
||||
if tariff_id:
|
||||
tariff_id_str = str(tariff_id)
|
||||
q_by_tariff = 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 == tariff_id_str,
|
||||
)
|
||||
)
|
||||
allowed_by_tariff = {n for (n,) in q_by_tariff.all()}
|
||||
if allowed_by_tariff:
|
||||
logger.debug(f"Найдены серверы по tariff_id={tariff_id}: {allowed_by_tariff}")
|
||||
return [s for s in cluster if s.get("server_name") in allowed_by_tariff]
|
||||
|
||||
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]
|
||||
|
||||
check_values = [target_subgroup]
|
||||
if tariff_id:
|
||||
check_values.append(str(tariff_id))
|
||||
|
||||
total_bindings = await session.scalar(
|
||||
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title.in_(check_values))
|
||||
)
|
||||
if not total_bindings:
|
||||
logger.info(f"Для подгруппы/тарифа нет привязок. Используем весь кластер {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
|
||||
|
||||
|
||||
async def filter_cluster_by_tariff(session: AsyncSession, cluster: list, tariff_id: int, cluster_id: str) -> list:
|
||||
names = [s.get("server_name") for s in cluster if s.get("server_name")]
|
||||
if not names:
|
||||
return []
|
||||
|
||||
tariff_id_str = str(tariff_id)
|
||||
|
||||
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 == tariff_id_str,
|
||||
)
|
||||
)
|
||||
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_tariff = await session.scalar(
|
||||
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title == tariff_id_str)
|
||||
)
|
||||
if not total_for_tariff:
|
||||
logger.info(f"Для тарифа {tariff_id} нет привязок серверов. Используем весь кластер {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"Нет серверов под тариф {tariff_id} в кластере {cluster_id}.")
|
||||
return []
|
||||
|
||||
logger.info(f"В кластере {cluster_id} нет привязок тарифов. Используем весь кластер.")
|
||||
return cluster
|
||||
|
||||
|
||||
async def has_legacy_subgroup_bindings(session: AsyncSession, server_ids: list[int]) -> bool:
|
||||
if not server_ids:
|
||||
return False
|
||||
|
||||
result = await session.execute(
|
||||
select(ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(server_ids))
|
||||
)
|
||||
for (title,) in result.all():
|
||||
if title and not title.isdigit():
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .models import Setting
|
||||
|
||||
|
||||
async def get_setting(session: AsyncSession, key: str, default: Any = None) -> Any:
|
||||
stmt = select(Setting).where(Setting.key == key)
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting is None or setting.value is None:
|
||||
return default
|
||||
return setting.value
|
||||
|
||||
|
||||
async def set_setting(
|
||||
session: AsyncSession,
|
||||
key: str,
|
||||
value: Any,
|
||||
description: str | None = None,
|
||||
) -> Setting:
|
||||
stmt = select(Setting).where(Setting.key == key)
|
||||
result = await session.execute(stmt)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = Setting(key=key, value=value, description=description)
|
||||
session.add(setting)
|
||||
else:
|
||||
setting.value = value
|
||||
if description is not None:
|
||||
setting.description = description
|
||||
|
||||
await session.flush()
|
||||
return setting
|
||||
@@ -0,0 +1,168 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import and_, exists, func, not_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
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_active_paid_keys(session: AsyncSession) -> int:
|
||||
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
|
||||
return await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Key)
|
||||
.where(Key.expiry_time > current_time_ms)
|
||||
.where(~Key.tariff_id.in_(trial_tariffs_subquery))
|
||||
)
|
||||
|
||||
|
||||
async def count_active_trial_keys(session: AsyncSession) -> int:
|
||||
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
|
||||
|
||||
return await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Key)
|
||||
.where(Key.expiry_time > current_time_ms)
|
||||
.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 get_tariff_subgroups(session: AsyncSession, tariff_ids: list[int]) -> dict[int, str | None]:
|
||||
if not tariff_ids:
|
||||
return {}
|
||||
|
||||
result = await session.execute(select(Tariff.id, Tariff.subgroup_title).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_(PAYMENT_SYSTEMS_EXCLUDED),
|
||||
)
|
||||
)
|
||||
)
|
||||
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_(PAYMENT_SYSTEMS_EXCLUDED),
|
||||
)
|
||||
)
|
||||
)
|
||||
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_(PAYMENT_SYSTEMS_EXCLUDED),
|
||||
)
|
||||
)
|
||||
)
|
||||
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_(PAYMENT_SYSTEMS_EXCLUDED))
|
||||
.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,270 @@
|
||||
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}")
|
||||
await session.rollback()
|
||||
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}")
|
||||
await session.rollback()
|
||||
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}")
|
||||
await session.rollback()
|
||||
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}")
|
||||
await session.rollback()
|
||||
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,43 @@
|
||||
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()
|
||||
raise
|
||||
|
||||
|
||||
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,255 @@
|
||||
from sqlalchemy import and_, func, insert, not_, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
|
||||
from database.models import Payment, TrackingSource, User
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
|
||||
try:
|
||||
stmt = insert(TrackingSource).values(
|
||||
name=name,
|
||||
code=code,
|
||||
type=type_,
|
||||
created_by=created_by,
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.info(f"🆕 Источник трафика {code} создан")
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при создании источника {code}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
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")
|
||||
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
|
||||
)
|
||||
.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")
|
||||
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
|
||||
& (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")
|
||||
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
|
||||
& (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")
|
||||
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
|
||||
& (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,267 @@
|
||||
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,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
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=["tg_id"])
|
||||
.returning(User.tg_id)
|
||||
)
|
||||
res = await session.execute(stmt)
|
||||
inserted_tg_id = res.scalar_one_or_none()
|
||||
if inserted_tg_id is None:
|
||||
return False
|
||||
if commit:
|
||||
await session.commit()
|
||||
logger.info(f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})")
|
||||
return True
|
||||
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:
|
||||
if amount < 0:
|
||||
current = await get_balance(session, tg_id)
|
||||
if current + amount < 0:
|
||||
logger.warning(f"[DB] Недостаточно средств: tg_id={tg_id} balance={current} списание={amount}")
|
||||
await session.rollback()
|
||||
raise ValueError(f"Недостаточно средств: баланс {current}, списание {amount}")
|
||||
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))
|
||||
balance = result.scalar_one_or_none()
|
||||
return round(float(balance or 0.0), 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()
|
||||
raise
|
||||
|
||||
|
||||
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()
|
||||
raise
|
||||
|
||||
|
||||
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))
|
||||
trial = result.scalar_one_or_none()
|
||||
return int(trial or 0)
|
||||
|
||||
|
||||
async def upsert_user(
|
||||
session: AsyncSession,
|
||||
tg_id: int,
|
||||
username: str = None,
|
||||
first_name: str = None,
|
||||
last_name: str = None,
|
||||
language_code: str = None,
|
||||
is_bot: bool = False,
|
||||
only_if_exists: bool = False,
|
||||
) -> dict | None:
|
||||
"""Создаёт пользователя или обновляет поля профиля."""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
returning_cols = list(User.__table__.c)
|
||||
|
||||
if only_if_exists:
|
||||
username_value = username if username else User.username
|
||||
first_name_value = first_name if first_name else User.first_name
|
||||
last_name_value = last_name if last_name else User.last_name
|
||||
language_code_value = language_code if language_code else User.language_code
|
||||
|
||||
res = await session.execute(
|
||||
update(User)
|
||||
.where(User.tg_id == tg_id)
|
||||
.values(
|
||||
username=username_value,
|
||||
first_name=first_name_value,
|
||||
last_name=last_name_value,
|
||||
language_code=language_code_value,
|
||||
is_bot=is_bot,
|
||||
updated_at=now,
|
||||
)
|
||||
.returning(*returning_cols)
|
||||
)
|
||||
row = res.mappings().one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
await session.commit()
|
||||
return dict(row)
|
||||
|
||||
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=now,
|
||||
updated_at=now,
|
||||
)
|
||||
.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": now,
|
||||
},
|
||||
)
|
||||
.returning(*returning_cols)
|
||||
)
|
||||
row = res.mappings().one()
|
||||
await session.commit()
|
||||
return dict(row)
|
||||
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, commit=False)
|
||||
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:
|
||||
keys_count_sq = select(func.count(Key.client_id)).where(Key.tg_id == tg_id).scalar_subquery()
|
||||
|
||||
res = await session.execute(select(func.coalesce(User.trial, 0), keys_count_sq).where(User.tg_id == tg_id))
|
||||
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,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
if not source_code:
|
||||
return False
|
||||
stmt = (
|
||||
insert(User)
|
||||
.values(tg_id=tg_id, source_code=source_code)
|
||||
.on_conflict_do_update(
|
||||
index_elements=["tg_id"],
|
||||
set_={"source_code": insert(User).excluded.source_code},
|
||||
where=(User.source_code.is_(None)),
|
||||
)
|
||||
.returning(User.tg_id)
|
||||
)
|
||||
res = await session.execute(stmt)
|
||||
changed_tg_id = res.scalar_one_or_none()
|
||||
if changed_tg_id is None:
|
||||
return False
|
||||
if commit:
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
bot:
|
||||
container_name: solobot
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /:/host:ro
|
||||
|
||||
+62
-9
@@ -1,17 +1,70 @@
|
||||
from typing import Union
|
||||
import time
|
||||
|
||||
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
|
||||
|
||||
|
||||
_ADMIN_CACHE: dict[int, tuple[float, bool, bool]] = {}
|
||||
_ADMIN_CACHE_TTL = 60
|
||||
|
||||
|
||||
def _get_cached_admin(user_id: int) -> tuple[bool, bool] | None:
|
||||
now = time.time()
|
||||
entry = _ADMIN_CACHE.get(user_id)
|
||||
if entry and entry[0] > now:
|
||||
return entry[1], entry[2]
|
||||
return None
|
||||
|
||||
|
||||
def _set_cached_admin(user_id: int, is_admin: bool, is_superadmin: bool) -> None:
|
||||
_ADMIN_CACHE[user_id] = (time.time() + _ADMIN_CACHE_TTL, is_admin, is_superadmin)
|
||||
|
||||
|
||||
class IsAdminFilter(BaseFilter):
|
||||
async def __call__(self, message: Message) -> bool:
|
||||
try:
|
||||
admin_ids: Union[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
|
||||
except Exception:
|
||||
async def __call__(self, event: Message | CallbackQuery) -> bool:
|
||||
if not event.from_user:
|
||||
return False
|
||||
|
||||
user_id = event.from_user.id
|
||||
cached = _get_cached_admin(user_id)
|
||||
if cached is not None:
|
||||
return cached[0]
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
admin = (await session.execute(select(Admin).where(Admin.tg_id == user_id))).scalar_one_or_none()
|
||||
admin_ids = (ADMIN_ID,) if isinstance(ADMIN_ID, int) else ADMIN_ID
|
||||
is_admin = admin is not None or user_id in admin_ids
|
||||
is_super = admin.role != "moderator" if admin else (user_id in admin_ids)
|
||||
_set_cached_admin(user_id, is_admin, is_super)
|
||||
return is_admin
|
||||
except (Exception,):
|
||||
return False
|
||||
|
||||
|
||||
class IsSuperAdminFilter(BaseFilter):
|
||||
async def __call__(self, event: Message | CallbackQuery) -> bool:
|
||||
if not event.from_user:
|
||||
return False
|
||||
|
||||
user_id = event.from_user.id
|
||||
cached = _get_cached_admin(user_id)
|
||||
if cached is not None:
|
||||
return cached[1]
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
admin = (await session.execute(select(Admin).where(Admin.tg_id == user_id))).scalar_one_or_none()
|
||||
if not admin:
|
||||
_set_cached_admin(user_id, False, False)
|
||||
return False
|
||||
is_super = admin.role != "moderator"
|
||||
_set_cached_admin(user_id, True, is_super)
|
||||
return is_super
|
||||
except (Exception,):
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from aiogram.enums import ChatType
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Chat, TelegramObject
|
||||
|
||||
|
||||
class IsPrivateFilter(BaseFilter):
|
||||
async def __call__(self, event: TelegramObject, event_chat: Chat) -> bool:
|
||||
return event_chat.type == ChatType.PRIVATE
|
||||
@@ -0,0 +1,34 @@
|
||||
__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 .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
|
||||
from .tariffs import router as tariff_router
|
||||
|
||||
|
||||
router = Router(name="handlers_main_router")
|
||||
|
||||
router.include_routers(
|
||||
start_router,
|
||||
captcha_router,
|
||||
profile_router,
|
||||
donate_router,
|
||||
coupons_router,
|
||||
notifications_router,
|
||||
payments_router,
|
||||
keys_router,
|
||||
instructions_router,
|
||||
admin_router,
|
||||
refferal_router,
|
||||
tariff_router,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .ads import router as ads_router
|
||||
from .backups import router as backups_router
|
||||
from .bans import router as bans_router
|
||||
from .clusters import router as clusters_router
|
||||
from .coupons import router as coupons_router
|
||||
from .emoji import router as emoji_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 .settings import router as settings_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,
|
||||
emoji_router,
|
||||
restart_router,
|
||||
bans_router,
|
||||
ads_router,
|
||||
tariffs_router,
|
||||
gifts_router,
|
||||
settings_router,
|
||||
)
|
||||
@@ -1,150 +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,290 +0,0 @@
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
import subprocess
|
||||
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()
|
||||
|
||||
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="backups"))
|
||||
builder.row(InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
await 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_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_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" # Заголовки CSV
|
||||
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,437 +0,0 @@
|
||||
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
|
||||
import asyncpg
|
||||
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,527 +0,0 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
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 CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import TOTAL_GB
|
||||
from database import 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()
|
||||
|
||||
|
||||
@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):
|
||||
username = message.text.strip().lstrip("@")
|
||||
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="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.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"restore_trial_{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="admin"))
|
||||
|
||||
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="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)
|
||||
@@ -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,103 @@
|
||||
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
|
||||
from handlers.buttons import BACK
|
||||
|
||||
|
||||
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=BACK,
|
||||
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,366 @@
|
||||
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 aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
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 BlockedUser, Key, ManualBan
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from .keyboard import (
|
||||
build_bans_kb,
|
||||
build_blocked_users_kb,
|
||||
build_manual_bans_kb,
|
||||
build_shadow_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"
|
||||
"🔒 <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_blocked_menu"), IsAdminFilter())
|
||||
async def handle_blocked_users_menu(callback_query: CallbackQuery):
|
||||
text_ = (
|
||||
"📛 <b>Забанившие бота</b>\n\n"
|
||||
"Пользователи, которые заблокировали бота вручную или удалили чат.\n"
|
||||
"⬇ Выберите действие:"
|
||||
)
|
||||
await callback_query.message.edit_text(text=text_, reply_markup=build_blocked_users_kb())
|
||||
|
||||
|
||||
def get_shadow_bans_menu_text() -> str:
|
||||
return (
|
||||
"👻 <b>Теневые баны</b>\n\n"
|
||||
"Пользователи, действия которых игнорируются ботом.\n"
|
||||
"Они не получают уведомлений о бане.\n\n"
|
||||
"💡 <b>Можно добавить несколько пользователей за раз:</b>\n"
|
||||
"Отправьте список Telegram ID (один на строке).\n"
|
||||
"Пример:\n<code>123456789\n987654321\n555666777</code>\n\n"
|
||||
"⬇ Выберите действие:"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_shadow_menu"), IsAdminFilter())
|
||||
async def handle_shadow_bans_menu(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_manual_menu"), IsAdminFilter())
|
||||
async def handle_manual_bans_menu(callback_query: CallbackQuery):
|
||||
text_ = "🔒 <b>Ручные баны</b>\n\nПользователи, которых вы забанили через админку.\n⬇ Выберите действие:"
|
||||
await callback_query.message.edit_text(text=text_, reply_markup=build_manual_bans_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter())
|
||||
async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_blocked_users_kb()
|
||||
try:
|
||||
result = await session.execute(select(BlockedUser.tg_id))
|
||||
banned_users = result.scalars().all()
|
||||
|
||||
csv_output = io.StringIO()
|
||||
writer = csv.writer(csv_output)
|
||||
writer.writerow(["tg_id"])
|
||||
|
||||
for tg_id in banned_users:
|
||||
writer.writerow([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_blocked_users_kb()
|
||||
try:
|
||||
stmt = select(BlockedUser.tg_id).outerjoin(Key, BlockedUser.tg_id == Key.tg_id).where(Key.tg_id.is_(None))
|
||||
result = await session.execute(stmt)
|
||||
blocked_ids = [row[0] for row in result.all()]
|
||||
|
||||
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 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 == "shadow_bans_export"), IsAdminFilter())
|
||||
async def handle_shadow_bans_export(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_shadow_bans_kb()
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.banned_by, ManualBan.until).where(
|
||||
ManualBan.reason == "shadow"
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
csv_output = io.StringIO()
|
||||
writer = csv.writer(csv_output)
|
||||
writer.writerow(["tg_id", "banned_at", "banned_by", "until"])
|
||||
|
||||
for user in rows:
|
||||
writer.writerow([user.tg_id, user.banned_at, user.banned_by, user.until])
|
||||
|
||||
csv_output.seek(0)
|
||||
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="shadow_bans.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 == "manual_bans_export"), IsAdminFilter())
|
||||
async def handle_manual_bans_export(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_manual_bans_kb()
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.reason, ManualBan.until, ManualBan.banned_by).where(
|
||||
or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
csv_output = io.StringIO()
|
||||
writer = csv.writer(csv_output)
|
||||
writer.writerow(["tg_id", "banned_at", "reason", "until", "banned_by"])
|
||||
|
||||
for user in rows:
|
||||
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until, user.banned_by])
|
||||
|
||||
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="📥 Экспорт вручную забаненных пользователей (CSV)",
|
||||
)
|
||||
except Exception as e:
|
||||
await callback_query.message.answer(
|
||||
text=f"❗ Ошибка при экспорте: {e}",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_blocked"), IsAdminFilter())
|
||||
async def handle_clear_blocked_users(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_blocked_users_kb()
|
||||
try:
|
||||
count_result = await session.execute(select(func.count()).select_from(BlockedUser))
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
if total_count == 0:
|
||||
await callback_query.message.answer(
|
||||
text="📂 Нет забанивших пользователей для очистки.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
await session.execute(delete(BlockedUser))
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=f"🗑️ Очищено {total_count} записей забанивших пользователей из базы данных.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
logger.info(f"[BANS] Очищено {total_count} записей из blocked_users")
|
||||
except Exception as e:
|
||||
logger.error(f"[BANS] Ошибка при очистке blocked_users: {e}")
|
||||
await callback_query.message.answer(
|
||||
text=f"❗ Ошибка при очистке забанивших пользователей: {e}",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_shadow"), IsAdminFilter())
|
||||
async def handle_clear_shadow_bans(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_shadow_bans_kb()
|
||||
try:
|
||||
count_result = await session.execute(
|
||||
select(func.count()).select_from(ManualBan).where(ManualBan.reason == "shadow")
|
||||
)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
if total_count == 0:
|
||||
await callback_query.message.answer(
|
||||
text="📂 Нет теневых банов для очистки.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
await session.execute(delete(ManualBan).where(ManualBan.reason == "shadow"))
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=f"🗑️ Очищено {total_count} записей теневых банов из базы данных.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
logger.info(f"[BANS] Очищено {total_count} записей теневых банов из manual_bans")
|
||||
except Exception as e:
|
||||
logger.error(f"[BANS] Ошибка при очистке теневых банов: {e}")
|
||||
await callback_query.message.answer(
|
||||
text=f"❗ Ошибка при очистке теневых банов: {e}",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_manual"), IsAdminFilter())
|
||||
async def handle_clear_manual_bans(callback_query: CallbackQuery, session: AsyncSession):
|
||||
kb = build_manual_bans_kb()
|
||||
try:
|
||||
count_result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(ManualBan)
|
||||
.where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None)))
|
||||
)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
if total_count == 0:
|
||||
await callback_query.message.answer(
|
||||
text="📂 Нет ручных банов для очистки.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
await session.execute(delete(ManualBan).where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))))
|
||||
await session.commit()
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=f"🗑️ Очищено {total_count} записей ручных банов из базы данных.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
logger.info(f"[BANS] Очищено {total_count} записей ручных банов из manual_bans")
|
||||
except Exception as e:
|
||||
logger.error(f"[BANS] Ошибка при очистке ручных банов: {e}")
|
||||
await callback_query.message.answer(
|
||||
text=f"❗ Ошибка при очистке ручных банов: {e}",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="❌ Отмена",
|
||||
callback_data=AdminPanelCallback(action="bans_cancel_preemptive").pack(),
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
"📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n"
|
||||
"Пример:\n<code>123456789\n987654321</code>",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_cancel_preemptive"), IsAdminFilter())
|
||||
async def handle_cancel_preemptive_ban(callback: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
await callback.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
|
||||
|
||||
|
||||
@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> пользователей.",
|
||||
reply_markup=build_shadow_bans_kb(),
|
||||
)
|
||||
await state.clear()
|
||||
@@ -0,0 +1,96 @@
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from handlers.buttons import BACK
|
||||
|
||||
|
||||
def build_bans_kb():
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="📛 Забанившие бота",
|
||||
callback_data=AdminPanelCallback(action="bans_blocked_menu").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="👻 Теневые баны",
|
||||
callback_data=AdminPanelCallback(action="bans_shadow_menu").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🔒 Ручные баны",
|
||||
callback_data=AdminPanelCallback(action="bans_manual_menu").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="management").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_blocked_users_kb():
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="📥 Экспорт",
|
||||
callback_data=AdminPanelCallback(action="bans_export").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Удалить забанивших",
|
||||
callback_data=AdminPanelCallback(action="bans_delete_banned").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Очистить забанивших",
|
||||
callback_data=AdminPanelCallback(action="bans_clear_blocked").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="bans").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_shadow_bans_kb():
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="📥 Экспорт",
|
||||
callback_data=AdminPanelCallback(action="shadow_bans_export").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="➕ Забанить заранее",
|
||||
callback_data=AdminPanelCallback(action="bans_preemptive").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Очистить теневые баны",
|
||||
callback_data=AdminPanelCallback(action="bans_clear_shadow").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="bans").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_manual_bans_kb():
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(
|
||||
text="📥 Экспорт",
|
||||
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🗑️ Очистить ручные баны",
|
||||
callback_data=AdminPanelCallback(action="bans_clear_manual").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="bans").pack(),
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,5 @@
|
||||
from . import cluster_manage, cluster_sync, cluster_tariffs, cluster_transfers, cluster_wizard # noqa
|
||||
from .base import AdminClusterStates, router
|
||||
|
||||
|
||||
__all__ = ["router", "AdminClusterStates"]
|
||||
@@ -0,0 +1,18 @@
|
||||
from aiogram import Router
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminClusterStates(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()
|
||||
waiting_for_days_input = State()
|
||||
waiting_for_new_cluster_name = State()
|
||||
waiting_for_new_server_name = State()
|
||||
waiting_for_server_transfer = State()
|
||||
waiting_for_cluster_transfer = State()
|
||||
@@ -0,0 +1,452 @@
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_servers, update_key_expiry
|
||||
from database.models import Key, Server, Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.operations import renew_key_in_cluster
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .base import AdminClusterStates, router
|
||||
from .keyboard import (
|
||||
AdminClusterCallback,
|
||||
AdminServerCallback,
|
||||
build_cluster_management_kb,
|
||||
build_manage_cluster_kb,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
async def handle_clusters_manage(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
result = await session.execute(
|
||||
select(Server.tariff_group)
|
||||
.where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.tariff_group.isnot(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
row = result.first()
|
||||
tariff_group = row[0] if row else "—"
|
||||
|
||||
result = await session.execute(select(Server.server_name).where(Server.cluster_name == cluster_name))
|
||||
server_names = [row[0] for row in result.all()]
|
||||
result = await session.execute(
|
||||
select(func.count(func.distinct(Key.tg_id))).where(
|
||||
(Key.server_id == cluster_name) | (Key.server_id.in_(server_names))
|
||||
)
|
||||
)
|
||||
user_count = result.scalar() or 0
|
||||
|
||||
result = await session.execute(
|
||||
select(func.count()).where((Key.server_id == cluster_name) | (Key.server_id.in_(server_names)))
|
||||
)
|
||||
subscription_count = result.scalar() or 0
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Управление кластером <code>{cluster_name}</code></b>\n\n"
|
||||
f"📁 <b>Тарифная группа:</b> <code>{tariff_group}</code>\n"
|
||||
f"👥 <b>Пользователей на кластере:</b> <code>{user_count}</code>\n"
|
||||
f"🔑 <b>Всего подписок:</b> <code>{subscription_count}</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_cluster_management_kb(cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
|
||||
async def handle_cluster_servers(callback: CallbackQuery, session: AsyncSession):
|
||||
cluster_name = callback.data.split("|", 1)[1]
|
||||
servers = await get_servers(session=session, include_enabled=True)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
from handlers.utils import ALLOWED_GROUP_CODES
|
||||
|
||||
allowed = set(ALLOWED_GROUP_CODES)
|
||||
lines = []
|
||||
for s in cluster_servers:
|
||||
tids = s.get("tariff_ids") or []
|
||||
subs = s.get("tariff_subgroups") or []
|
||||
if tids:
|
||||
subs_str = f"{len(tids)} тариф(ов)"
|
||||
elif subs:
|
||||
subs_str = ", ".join(sorted(subs))
|
||||
else:
|
||||
subs_str = "—"
|
||||
|
||||
grps = s.get("special_groups") or []
|
||||
grps = [g for g in grps if g in allowed]
|
||||
grps_str = ", ".join(sorted(grps)) if grps else "—"
|
||||
|
||||
lines.append(f"• {s.get('server_name', '?')} — {subs_str} | {grps_str}")
|
||||
|
||||
details = "\n".join(lines) if lines else "нет серверов"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=(
|
||||
f"<b>📡 Серверы в кластере {cluster_name}</b>\n<i>подгруппы | спецгруппы:</i>\n"
|
||||
f"<blockquote>{details}</blockquote>"
|
||||
),
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
|
||||
async def handle_add_time(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
state: FSMContext,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
await state.set_state(AdminClusterStates.waiting_for_days_input)
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
f"⏳ Введите количество дней, на которое хотите продлить все подписки в кластере <b>{cluster_name}</b>:",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_days_input, IsAdminFilter())
|
||||
async def handle_days_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||
try:
|
||||
days = int(message.text.strip())
|
||||
if days <= 0:
|
||||
raise ValueError
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
add_ms = days * 86400 * 1000
|
||||
|
||||
logger.info(f"[Cluster Extend] Добавляем {days} дней для кластера: {cluster_name}")
|
||||
|
||||
server_stmt = select(Server.server_name).where(Server.cluster_name == cluster_name)
|
||||
server_rows = await session.execute(server_stmt)
|
||||
server_names = [row[0] for row in server_rows.all()]
|
||||
server_names.append(cluster_name)
|
||||
|
||||
result = await session.execute(select(Key).where(Key.server_id.in_(server_names)))
|
||||
keys = result.scalars().all()
|
||||
|
||||
if not keys:
|
||||
await message.answer("❌ Нет подписок в этом кластере или сервере.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
servers = await get_servers(session=session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
if not cluster_servers:
|
||||
await message.answer("❌ Не найдены серверы в кластере.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
is_full_remnawave = all(str(s.get("panel_type", "")).lower() == "remnawave" for s in cluster_servers)
|
||||
|
||||
if is_full_remnawave:
|
||||
uuids = [key.client_id for key in keys if key.client_id]
|
||||
|
||||
if not uuids:
|
||||
await message.answer("❌ Нет валидных подписок для продления.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
api_url = cluster_servers[0].get("api_url", "")
|
||||
if not api_url:
|
||||
await message.answer("❌ Не найден URL панели для кластера.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
remna = RemnawaveAPI(api_url)
|
||||
|
||||
try:
|
||||
result_bulk = await remna.bulk_extend_expiration_date(uuids, days)
|
||||
finally:
|
||||
await remna.aclose()
|
||||
|
||||
if result_bulk is None:
|
||||
await message.answer("❌ Ошибка при обращении к API панели.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
affected = result_bulk.get("affectedRows", 0)
|
||||
logger.info(f"[Cluster Extend] Bulk API: продлено {affected} подписок на {days} дней")
|
||||
|
||||
for key in keys:
|
||||
new_expiry = key.expiry_time + add_ms
|
||||
await update_key_expiry(session, key.client_id, new_expiry)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await message.answer(
|
||||
f"✅ Время подписки продлено на <b>{days} дней</b> для <b>{affected}</b> пользователей в кластере <b>{cluster_name}</b>."
|
||||
)
|
||||
else:
|
||||
for key in keys:
|
||||
new_expiry = key.expiry_time + add_ms
|
||||
|
||||
traffic_limit = 0
|
||||
device_limit = 0
|
||||
key_subgroup = None
|
||||
if key.tariff_id:
|
||||
tariff_result = await session.execute(
|
||||
select(Tariff.traffic_limit, Tariff.device_limit, Tariff.subgroup_title).where(
|
||||
Tariff.id == key.tariff_id,
|
||||
Tariff.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
tariff = tariff_result.first()
|
||||
if tariff:
|
||||
traffic_limit = int(tariff[0]) if tariff[0] is not None else 0
|
||||
device_limit = int(tariff[1]) if tariff[1] is not None else 0
|
||||
key_subgroup = tariff[2]
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_name,
|
||||
email=key.email,
|
||||
client_id=key.client_id,
|
||||
new_expiry_time=new_expiry,
|
||||
total_gb=traffic_limit,
|
||||
session=session,
|
||||
hwid_device_limit=device_limit,
|
||||
reset_traffic=False,
|
||||
target_subgroup=key_subgroup,
|
||||
old_subgroup=key_subgroup,
|
||||
plan=key.tariff_id,
|
||||
)
|
||||
await update_key_expiry(session, key.client_id, new_expiry)
|
||||
|
||||
logger.info(f"[Cluster Extend] {key.email} +{days}д → {datetime.utcfromtimestamp(new_expiry / 1000)}")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число дней.")
|
||||
except Exception as e:
|
||||
logger.error(f"[Cluster Extend] Ошибка при добавлении дней: {e}")
|
||||
await message.answer("❌ Произошла ошибка при продлении времени.")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "rename"), IsAdminFilter())
|
||||
async def handle_rename_cluster(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
state: FSMContext,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
await state.update_data(old_cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"✏️ <b>Введите новое имя для кластера '{cluster_name}':</b>\n\n"
|
||||
"▸ Имя должно быть уникальным.\n"
|
||||
"▸ Имя не должно превышать 12 символов.\n\n"
|
||||
"📌 <i>Пример:</i> <code>new_cluster</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_new_cluster_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_new_cluster_name, IsAdminFilter())
|
||||
async def handle_new_cluster_name_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
new_cluster_name = message.text.strip()
|
||||
if len(new_cluster_name) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
old_cluster_name = user_data.get("old_cluster_name")
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Server.cluster_name).where(Server.cluster_name == new_cluster_name).limit(1)
|
||||
)
|
||||
existing_cluster = result.scalar()
|
||||
|
||||
if existing_cluster:
|
||||
await message.answer(
|
||||
text=f"❌ Кластер с именем '{new_cluster_name}' уже существует. Введите другое имя.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
keys_count_result = await session.execute(
|
||||
select(func.count()).select_from(Key).where(Key.server_id == old_cluster_name)
|
||||
)
|
||||
keys_count = keys_count_result.scalar()
|
||||
|
||||
await session.execute(
|
||||
update(Server).where(Server.cluster_name == old_cluster_name).values(cluster_name=new_cluster_name)
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await session.execute(
|
||||
update(Key).where(Key.server_id == old_cluster_name).values(server_id=new_cluster_name)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await message.answer(
|
||||
text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Ошибка при смене имени кластера {old_cluster_name} на {new_cluster_name}: {e}")
|
||||
await message.answer(
|
||||
text=f"❌ Произошла ошибка при смене имени кластера: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "rename"), IsAdminFilter())
|
||||
async def handle_rename_server(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminServerCallback,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
):
|
||||
old_server_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session=session)
|
||||
cluster_name = None
|
||||
for c_name, server_list in servers.items():
|
||||
for server in server_list:
|
||||
if server["server_name"] == old_server_name:
|
||||
cluster_name = c_name
|
||||
break
|
||||
if cluster_name:
|
||||
break
|
||||
|
||||
if not cluster_name:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Не удалось найти кластер для сервера '{old_server_name}'.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(old_server_name=old_server_name, cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"✏️ <b>Введите новое имя для сервера '{old_server_name}' в кластере '{cluster_name}':</b>\n\n"
|
||||
"▸ Имя должно быть уникальным в пределах кластера.\n"
|
||||
"▸ Имя не должно превышать 12 символов.\n\n"
|
||||
"📌 <i>Пример:</i> <code>new_server</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_new_server_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_new_server_name, IsAdminFilter())
|
||||
async def handle_new_server_name_input(message: Message, state: FSMContext, session: AsyncSession):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не может быть пустым! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
new_server_name = message.text.strip()
|
||||
if len(new_server_name) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не должно превышать 12 символов! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
old_server_name = user_data.get("old_server_name")
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Server)
|
||||
.where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.server_name == new_server_name,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
existing_server = result.scalar()
|
||||
if existing_server:
|
||||
await message.answer(
|
||||
text=(
|
||||
f"❌ Сервер с именем '{new_server_name}' уже существует в кластере '{cluster_name}'. "
|
||||
f"Введите другое имя."
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
result = await session.execute(select(func.count()).select_from(Key).where(Key.server_id == old_server_name))
|
||||
keys_count = result.scalar()
|
||||
|
||||
await session.execute(
|
||||
update(Server)
|
||||
.where(
|
||||
Server.cluster_name == cluster_name,
|
||||
Server.server_name == old_server_name,
|
||||
)
|
||||
.values(server_name=new_server_name)
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
|
||||
|
||||
await session.commit()
|
||||
|
||||
await message.answer(
|
||||
text=(
|
||||
f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' "
|
||||
f"в кластере '{cluster_name}'!"
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}")
|
||||
await message.answer(
|
||||
text=f"❌ Произошла ошибка при смене имени сервера: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await state.clear()
|
||||
@@ -0,0 +1,820 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, types
|
||||
from aiogram.types import CallbackQuery
|
||||
from py3xui import AsyncApi
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from core.bootstrap import MODES_CONFIG
|
||||
from database import get_servers
|
||||
from database.models import Key, Server, Tariff
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.operations import (
|
||||
create_client_on_server,
|
||||
create_key_on_cluster,
|
||||
delete_key_from_cluster,
|
||||
)
|
||||
from handlers.keys.operations.aggregated_links import make_aggregated_link
|
||||
from handlers.utils import ALLOWED_GROUP_CODES
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from utils.backup import create_backup_and_send_to_admins
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .base import router
|
||||
from .keyboard import AdminClusterCallback, build_availability_kb, build_sync_cluster_kb
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter())
|
||||
async def handle_cluster_availability(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: Any,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
if not cluster_servers:
|
||||
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=(
|
||||
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
|
||||
"Это может занять до 1 минуты, пожалуйста, подождите..."
|
||||
)
|
||||
)
|
||||
|
||||
total_online_users = 0
|
||||
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
|
||||
|
||||
for server in cluster_servers:
|
||||
server_name = server["server_name"]
|
||||
panel_type = server.get("panel_type", "3x-ui").lower()
|
||||
prefix = "[3x]" if panel_type == "3x-ui" else "[Re]"
|
||||
|
||||
try:
|
||||
if panel_type == "3x-ui":
|
||||
xui = AsyncApi(
|
||||
server["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=None,
|
||||
)
|
||||
await xui.login()
|
||||
inbound_id = int(server["inbound_id"])
|
||||
online_clients = await xui.client.online()
|
||||
online_inbound_users = 0
|
||||
|
||||
for client_email in online_clients:
|
||||
client = await xui.client.get_by_email(client_email)
|
||||
if client and client.inbound_id == inbound_id:
|
||||
online_inbound_users += 1
|
||||
|
||||
total_online_users += online_inbound_users
|
||||
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_inbound_users} онлайн\n"
|
||||
|
||||
elif panel_type == "remnawave":
|
||||
server_inbound_id = server.get("inbound_id")
|
||||
if not server_inbound_id:
|
||||
raise Exception("Не указан inbound_id сервера")
|
||||
|
||||
remna = RemnawaveAPI(server["api_url"])
|
||||
nodes_data = await remna.get_all_nodes_with_online(
|
||||
username=REMNAWAVE_LOGIN,
|
||||
password=REMNAWAVE_PASSWORD,
|
||||
inbound_id=server_inbound_id,
|
||||
)
|
||||
|
||||
if nodes_data.get("error"):
|
||||
raise Exception(nodes_data["error"])
|
||||
|
||||
online_remna_users = nodes_data["total_online"]
|
||||
total_online_users += online_remna_users
|
||||
|
||||
nodes_info = nodes_data["nodes"]
|
||||
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
|
||||
seen = set()
|
||||
for node_info in nodes_info:
|
||||
node_name = node_info.get("name", "Unknown")
|
||||
if node_name in seen:
|
||||
continue
|
||||
seen.add(node_name)
|
||||
|
||||
country_code = node_info.get("country_code", "Unknown")
|
||||
online_users = node_info.get("online_users", 0)
|
||||
|
||||
flag = (
|
||||
"".join(chr(ord(c) + 127397) for c in country_code.upper())
|
||||
if country_code != "Unknown" and len(country_code) == 2
|
||||
else country_code
|
||||
)
|
||||
result_text += f" ↳ {flag} ({node_name}): {online_users} онлайн\n"
|
||||
|
||||
except Exception as e:
|
||||
error_text = str(e) or "Сервер недоступен"
|
||||
result_text += f"❌ <b>{prefix} {server_name}</b> - ошибка: {error_text}\n"
|
||||
|
||||
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
|
||||
await callback_query.message.edit_text(
|
||||
text=result_text,
|
||||
reply_markup=build_availability_kb(cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter())
|
||||
async def handle_clusters_backup(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: Any,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
for server in cluster_servers:
|
||||
if server.get("panel_type") == "remnawave":
|
||||
continue
|
||||
|
||||
xui = AsyncApi(
|
||||
server["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
await create_backup_and_send_to_admins(xui)
|
||||
|
||||
text = (
|
||||
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
|
||||
f"🔔 <i>Бэкапы отправлены в боты панелей (3x-ui).</i>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync"), IsAdminFilter())
|
||||
async def handle_sync(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: Any,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>",
|
||||
reply_markup=build_sync_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter())
|
||||
async def handle_sync_server(
|
||||
callback_query: types.CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
server_name = callback_data.data
|
||||
|
||||
try:
|
||||
server_result = await session.execute(
|
||||
select(Server.cluster_name).where(Server.server_name == server_name).limit(1)
|
||||
)
|
||||
cluster_name = server_result.scalar()
|
||||
|
||||
if not cluster_name:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Сервер {server_name} не найден.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||
|
||||
if use_country_selection:
|
||||
stmt = (
|
||||
select(
|
||||
Server.api_url,
|
||||
Server.inbound_id,
|
||||
Server.server_name,
|
||||
Server.panel_type,
|
||||
Key.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.expiry_time,
|
||||
Key.tariff_id,
|
||||
Key.remnawave_link,
|
||||
Key.selected_device_limit,
|
||||
Key.selected_traffic_limit,
|
||||
Key.current_device_limit,
|
||||
Key.current_traffic_limit,
|
||||
)
|
||||
.join(Key, Server.server_name == Key.server_id)
|
||||
.where(Server.server_name == server_name)
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(
|
||||
Server.api_url,
|
||||
Server.inbound_id,
|
||||
Server.server_name,
|
||||
Server.panel_type,
|
||||
Key.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.expiry_time,
|
||||
Key.tariff_id,
|
||||
Key.remnawave_link,
|
||||
Key.selected_device_limit,
|
||||
Key.selected_traffic_limit,
|
||||
Key.current_device_limit,
|
||||
Key.current_traffic_limit,
|
||||
)
|
||||
.join(Key, Server.cluster_name == Key.server_id)
|
||||
.where(Server.server_name == server_name)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
keys_to_sync = result.mappings().all()
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Нет ключей для синхронизации в сервере {server_name}.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
)
|
||||
|
||||
all_servers = await get_servers(session)
|
||||
cluster_servers = all_servers.get(cluster_name, [])
|
||||
|
||||
server_info = None
|
||||
for s in cluster_servers:
|
||||
if s.get("server_name") == server_name:
|
||||
server_info = s
|
||||
break
|
||||
|
||||
tariff_ids = {key["tariff_id"] for key in keys_to_sync if key["tariff_id"]}
|
||||
tariffs_cache = {}
|
||||
if tariff_ids:
|
||||
tariffs_result = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
|
||||
tariffs_list = tariffs_result.scalars().all()
|
||||
tariffs_cache = {t.id: dict(t.__dict__) for t in tariffs_list}
|
||||
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
if key["panel_type"] == "remnawave":
|
||||
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
|
||||
|
||||
if tariff and server_info:
|
||||
subgroup = tariff.get("subgroup_title")
|
||||
tid = key["tariff_id"]
|
||||
has_new_binding = tid and tid in (server_info.get("tariff_ids") or [])
|
||||
has_old_binding = subgroup and subgroup in (server_info.get("tariff_subgroups") or [])
|
||||
has_any_binding = bool(server_info.get("tariff_ids") or server_info.get("tariff_subgroups"))
|
||||
|
||||
if has_any_binding and subgroup and not has_new_binding and not has_old_binding:
|
||||
continue
|
||||
|
||||
if tariff.get("group_code") and tariff.get("group_code").lower() in ALLOWED_GROUP_CODES:
|
||||
if tariff.get("group_code").lower() not in server_info.get("special_groups", []):
|
||||
continue
|
||||
|
||||
expire_iso = (
|
||||
datetime.utcfromtimestamp(key["expiry_time"] / 1000).replace(tzinfo=timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
remna = RemnawaveAPI(key["api_url"])
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
logger.error(f"Не удалось авторизоваться в Remnawave для сервера {server_name}")
|
||||
continue
|
||||
|
||||
traffic_limit_bytes = 0
|
||||
hwid_limit = 0
|
||||
|
||||
current_device_limit_from_key = key.get("current_device_limit")
|
||||
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
|
||||
selected_device_limit_from_key = key.get("selected_device_limit")
|
||||
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
|
||||
|
||||
if tariff:
|
||||
if current_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
|
||||
elif selected_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
|
||||
elif tariff.get("traffic_limit") is not None:
|
||||
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
|
||||
|
||||
if current_device_limit_from_key is not None:
|
||||
hwid_limit = int(current_device_limit_from_key)
|
||||
elif selected_device_limit_from_key is not None:
|
||||
hwid_limit = int(selected_device_limit_from_key)
|
||||
else:
|
||||
hwid_limit = tariff.get("device_limit")
|
||||
|
||||
success = await remna.update_user(
|
||||
uuid=key["client_id"],
|
||||
expire_at=expire_iso,
|
||||
telegram_id=key["tg_id"],
|
||||
email=f"{key['email']}@fake.local",
|
||||
active_user_inbounds=[key["inbound_id"]],
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
hwid_device_limit=hwid_limit,
|
||||
)
|
||||
|
||||
if success:
|
||||
try:
|
||||
sub = await remna.get_subscription_by_username(key["email"])
|
||||
if sub:
|
||||
new_remnawave_link = sub.get("subscriptionUrl")
|
||||
|
||||
if new_remnawave_link:
|
||||
key_value = await make_aggregated_link(
|
||||
session=session,
|
||||
cluster_all=cluster_servers,
|
||||
cluster_id=cluster_name,
|
||||
email=key["email"],
|
||||
client_id=key["client_id"],
|
||||
tg_id=key["tg_id"],
|
||||
remna_link_override=None,
|
||||
plan=tariff,
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
|
||||
.values(remnawave_link=new_remnawave_link, key=key_value)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"[Sync] Обновлена ссылка для {key['email']}: {new_remnawave_link}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Sync] Не удалось получить ссылку для {key['email']}: {e}")
|
||||
|
||||
if not success:
|
||||
logger.warning("[Sync] ошибка обновления, пробуем пересоздать")
|
||||
|
||||
await delete_key_from_cluster(server_name, key["email"], key["client_id"], session)
|
||||
|
||||
await create_key_on_cluster(
|
||||
cluster_id=server_name,
|
||||
tg_id=key["tg_id"],
|
||||
client_id=key["client_id"],
|
||||
email=key["email"],
|
||||
expiry_timestamp=key["expiry_time"],
|
||||
plan=key["tariff_id"],
|
||||
session=session,
|
||||
remnawave_link=key["remnawave_link"],
|
||||
hwid_limit=hwid_limit,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
selected_device_limit=key.get("selected_device_limit"),
|
||||
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
|
||||
current_device_limit=key.get("current_device_limit"),
|
||||
current_traffic_limit_gb=key.get("current_traffic_limit"),
|
||||
selected_price_rub=key.get("selected_price_rub"),
|
||||
)
|
||||
else:
|
||||
await create_client_on_server(
|
||||
{
|
||||
"api_url": key["api_url"],
|
||||
"inbound_id": key["inbound_id"],
|
||||
"server_name": key["server_name"],
|
||||
},
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
semaphore,
|
||||
plan=key["tariff_id"],
|
||||
session=session,
|
||||
)
|
||||
await asyncio.sleep(0.6)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в сервер {server_name}: {e}")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации ключей для сервера {server_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при синхронизации: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter())
|
||||
async def handle_sync_cluster(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminClusterCallback,
|
||||
session: AsyncSession,
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
try:
|
||||
servers = await get_servers(session)
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
|
||||
|
||||
if use_country_selection:
|
||||
server_names = [s.get("server_name") for s in cluster_servers if s.get("server_name")]
|
||||
if not server_names:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ В кластере {cluster_name} нет серверов.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
result = await session.execute(
|
||||
select(
|
||||
Key.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.expiry_time,
|
||||
Key.remnawave_link,
|
||||
Key.tariff_id,
|
||||
Key.server_id,
|
||||
Key.selected_device_limit,
|
||||
Key.selected_traffic_limit,
|
||||
Key.current_device_limit,
|
||||
Key.current_traffic_limit,
|
||||
).where(Key.server_id.in_(server_names), Key.is_frozen.is_(False))
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(
|
||||
Key.tg_id,
|
||||
Key.client_id,
|
||||
Key.email,
|
||||
Key.expiry_time,
|
||||
Key.remnawave_link,
|
||||
Key.tariff_id,
|
||||
Key.server_id,
|
||||
Key.selected_device_limit,
|
||||
Key.selected_traffic_limit,
|
||||
Key.current_device_limit,
|
||||
Key.current_traffic_limit,
|
||||
).where(Key.server_id == cluster_name, Key.is_frozen.is_(False))
|
||||
)
|
||||
|
||||
keys_to_sync = result.mappings().all()
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
only_remnawave = all(s.get("panel_type") == "remnawave" for s in cluster_servers)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
)
|
||||
|
||||
tariff_ids = {key["tariff_id"] for key in keys_to_sync if key["tariff_id"]}
|
||||
tariffs_cache = {}
|
||||
if tariff_ids:
|
||||
tariffs_result = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
|
||||
tariffs_list = tariffs_result.scalars().all()
|
||||
tariffs_cache = {t.id: dict(t.__dict__) for t in tariffs_list}
|
||||
|
||||
if only_remnawave:
|
||||
batch_size = 50
|
||||
total_keys = len(keys_to_sync)
|
||||
processed_count = 0
|
||||
|
||||
for batch_start in range(0, total_keys, batch_size):
|
||||
batch = keys_to_sync[batch_start : batch_start + batch_size]
|
||||
batch_end = batch_start + len(batch)
|
||||
logger.info(f"[Sync] Обработка батча {batch_start}-{batch_end} из {total_keys}")
|
||||
|
||||
async def update_remnawave_api(key):
|
||||
try:
|
||||
traffic_limit_bytes = 0
|
||||
hwid_limit = 0
|
||||
subgroup_title = None
|
||||
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
|
||||
|
||||
current_device_limit_from_key = key.get("current_device_limit")
|
||||
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
|
||||
selected_device_limit_from_key = key.get("selected_device_limit")
|
||||
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
|
||||
|
||||
if tariff:
|
||||
if current_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
|
||||
elif selected_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
|
||||
elif tariff.get("traffic_limit") is not None:
|
||||
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
|
||||
else:
|
||||
traffic_limit_bytes = 0
|
||||
|
||||
if current_device_limit_from_key is not None:
|
||||
hwid_limit = int(current_device_limit_from_key)
|
||||
elif selected_device_limit_from_key is not None:
|
||||
hwid_limit = int(selected_device_limit_from_key)
|
||||
else:
|
||||
hwid_limit = tariff.get("device_limit")
|
||||
|
||||
subgroup_title = tariff.get("subgroup_title")
|
||||
|
||||
expire_iso = (
|
||||
datetime.utcfromtimestamp(key["expiry_time"] / 1000)
|
||||
.replace(tzinfo=timezone.utc)
|
||||
.isoformat()
|
||||
)
|
||||
|
||||
if use_country_selection:
|
||||
user_server = None
|
||||
for s in cluster_servers:
|
||||
if s.get("server_name") == key["server_id"]:
|
||||
user_server = s
|
||||
break
|
||||
|
||||
if not user_server:
|
||||
return {"key": key, "success": False, "error": "Server not found"}
|
||||
|
||||
remna = RemnawaveAPI(user_server["api_url"])
|
||||
inbound_ids = [user_server["inbound_id"]] if user_server.get("inbound_id") else []
|
||||
else:
|
||||
remna = RemnawaveAPI(cluster_servers[0]["api_url"])
|
||||
|
||||
filtered_servers = cluster_servers
|
||||
if subgroup_title or (tariff and tariff.get("id")):
|
||||
tid = tariff.get("id") if tariff else None
|
||||
filtered_servers = [
|
||||
s
|
||||
for s in cluster_servers
|
||||
if (tid and tid in (s.get("tariff_ids") or []))
|
||||
or (subgroup_title and subgroup_title in (s.get("tariff_subgroups") or []))
|
||||
]
|
||||
if not filtered_servers:
|
||||
filtered_servers = cluster_servers
|
||||
|
||||
if tariff and tariff.get("group_code"):
|
||||
group_code = tariff.get("group_code").lower()
|
||||
if group_code in ALLOWED_GROUP_CODES:
|
||||
special_filtered = [
|
||||
s for s in filtered_servers if group_code in (s.get("special_groups") or [])
|
||||
]
|
||||
if special_filtered:
|
||||
filtered_servers = special_filtered
|
||||
|
||||
inbound_ids = [s["inbound_id"] for s in filtered_servers if s.get("inbound_id")]
|
||||
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
return {"key": key, "success": False, "error": "Login failed"}
|
||||
|
||||
success = await remna.update_user(
|
||||
uuid=key["client_id"],
|
||||
expire_at=expire_iso,
|
||||
telegram_id=key["tg_id"],
|
||||
email=f"{key['email']}@fake.local",
|
||||
active_user_inbounds=inbound_ids,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
hwid_device_limit=hwid_limit,
|
||||
)
|
||||
|
||||
if success:
|
||||
sub = await remna.get_subscription_by_username(key["email"])
|
||||
new_link = sub.get("subscriptionUrl") if sub else None
|
||||
return {
|
||||
"key": key,
|
||||
"success": True,
|
||||
"new_link": new_link,
|
||||
"tariff": tariff,
|
||||
"traffic_limit_bytes": traffic_limit_bytes,
|
||||
"hwid_limit": hwid_limit,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"key": key,
|
||||
"success": False,
|
||||
"needs_recreate": True,
|
||||
"tariff": tariff,
|
||||
"traffic_limit_bytes": traffic_limit_bytes,
|
||||
"hwid_limit": hwid_limit,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Ошибка API для {key.get('email')}: {e}")
|
||||
return {"key": key, "success": False, "error": str(e)}
|
||||
|
||||
tasks = [update_remnawave_api(key) for key in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
bulk_updates = []
|
||||
recreate_tasks = []
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"[Sync] Exception в батче: {result}")
|
||||
continue
|
||||
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
|
||||
key = result.get("key")
|
||||
if not key:
|
||||
continue
|
||||
|
||||
try:
|
||||
if result.get("success") and result.get("new_link"):
|
||||
new_link = result["new_link"]
|
||||
tariff = result.get("tariff")
|
||||
|
||||
key_value = await make_aggregated_link(
|
||||
session=session,
|
||||
cluster_all=cluster_servers,
|
||||
cluster_id=cluster_name,
|
||||
email=key["email"],
|
||||
client_id=key["client_id"],
|
||||
tg_id=key["tg_id"],
|
||||
remna_link_override=None,
|
||||
plan=tariff,
|
||||
)
|
||||
|
||||
bulk_updates.append({
|
||||
"client_id": key["client_id"],
|
||||
"remnawave_link": new_link,
|
||||
"key": key_value,
|
||||
})
|
||||
|
||||
elif result.get("needs_recreate"):
|
||||
recreate_tasks.append((key, result))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Ошибка подготовки для {key.get('email')}: {e}")
|
||||
|
||||
if bulk_updates:
|
||||
try:
|
||||
await session.run_sync(
|
||||
lambda sync_session: sync_session.bulk_update_mappings(Key, bulk_updates)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"[Sync] Bulk: обновлено {len(bulk_updates)} ключей")
|
||||
except Exception as bulk_error:
|
||||
logger.warning(f"[Sync] Bulk упал, fallback: {bulk_error}")
|
||||
await session.rollback()
|
||||
|
||||
for upd in bulk_updates:
|
||||
try:
|
||||
await session.execute(
|
||||
update(Key)
|
||||
.where(Key.client_id == upd["client_id"])
|
||||
.values(remnawave_link=upd["remnawave_link"], key=upd["key"])
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Fallback ошибка {upd['client_id']}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
for key, result in recreate_tasks:
|
||||
try:
|
||||
logger.warning(f"[Sync] Пересоздание {key['email']}")
|
||||
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"], session)
|
||||
await session.execute(
|
||||
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
cluster_id_for_recreate = key["server_id"] if use_country_selection else cluster_name
|
||||
await create_key_on_cluster(
|
||||
cluster_id_for_recreate,
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
plan=key["tariff_id"],
|
||||
session=session,
|
||||
remnawave_link=key["remnawave_link"],
|
||||
hwid_limit=result.get("hwid_limit"),
|
||||
traffic_limit_bytes=result.get("traffic_limit_bytes"),
|
||||
selected_device_limit=key.get("selected_device_limit"),
|
||||
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
|
||||
current_device_limit=key.get("current_device_limit"),
|
||||
current_traffic_limit_gb=key.get("current_traffic_limit"),
|
||||
selected_price_rub=key.get("selected_price_rub"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Пересоздание ошибка {key.get('email')}: {e}")
|
||||
|
||||
processed_count = batch_end
|
||||
progress_percent = int((processed_count / total_keys) * 100)
|
||||
progress_bar = "█" * (progress_percent // 5) + "░" * (20 - progress_percent // 5)
|
||||
|
||||
try:
|
||||
await callback_query.message.edit_text(
|
||||
text=(
|
||||
f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n"
|
||||
f"🔑 Количество ключей: <b>{total_keys}</b>\n\n"
|
||||
f"Обработано: <b>{processed_count}/{total_keys}</b>\n"
|
||||
f"<code>{progress_bar}</code>"
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
else:
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
traffic_limit_bytes = 0
|
||||
hwid_limit = 0
|
||||
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
|
||||
|
||||
current_device_limit_from_key = key.get("current_device_limit")
|
||||
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
|
||||
selected_device_limit_from_key = key.get("selected_device_limit")
|
||||
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
|
||||
|
||||
if tariff:
|
||||
if current_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
|
||||
elif selected_traffic_limit_gb_from_key is not None:
|
||||
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
|
||||
elif tariff.get("traffic_limit") is not None:
|
||||
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
|
||||
else:
|
||||
traffic_limit_bytes = 0
|
||||
|
||||
if current_device_limit_from_key is not None:
|
||||
hwid_limit = int(current_device_limit_from_key)
|
||||
elif selected_device_limit_from_key is not None:
|
||||
hwid_limit = int(selected_device_limit_from_key)
|
||||
else:
|
||||
hwid_limit = tariff.get("device_limit")
|
||||
|
||||
tariff.get("subgroup_title")
|
||||
elif key["tariff_id"]:
|
||||
logger.warning(
|
||||
f"[Sync] Ключ {key['client_id']} с несуществующим тарифом ID={key['tariff_id']} — "
|
||||
f"обновим без лимитов"
|
||||
)
|
||||
|
||||
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"], session)
|
||||
|
||||
await session.execute(
|
||||
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
|
||||
)
|
||||
|
||||
cluster_id_for_recreate = key["server_id"] if use_country_selection else cluster_name
|
||||
await create_key_on_cluster(
|
||||
cluster_id_for_recreate,
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
plan=key["tariff_id"],
|
||||
session=session,
|
||||
remnawave_link=key["remnawave_link"],
|
||||
hwid_limit=hwid_limit,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
selected_device_limit=key.get("selected_device_limit"),
|
||||
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
|
||||
current_device_limit=key.get("current_device_limit"),
|
||||
current_traffic_limit_gb=key.get("current_traffic_limit"),
|
||||
selected_price_rub=key.get("selected_price_rub"),
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Ошибка при обработке ключа {key['client_id']} в {cluster_name}: {e}")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=(
|
||||
f"✅ <b>Синхронизация завершена</b>\n\n"
|
||||
f"📊 Кластер: <b>{cluster_name}</b>\n"
|
||||
f"🔑 Обработано ключей: <b>{len(keys_to_sync)}</b>"
|
||||
),
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Sync] Ошибка синхронизации кластера {cluster_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при синхронизации: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user