Compare commits
1050 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 = 250
|
||||
ignore = E203, E266, E501, W503, F541, E704, W293, W291, E126, E121, E123, E128, E302, E131, E231, W292, E402, E261, E305
|
||||
max-complexity = 25
|
||||
select = B, C, E, F, W, T4, B9
|
||||
exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build,.txt
|
||||
@@ -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 |
+8
-1
@@ -50,4 +50,11 @@ handlers/texts.py
|
||||
Thumbs.db
|
||||
|
||||
nginx.conf
|
||||
scripts/nginx.sh
|
||||
scripts
|
||||
models.py
|
||||
Dockerfile
|
||||
.csv
|
||||
/logs
|
||||
setup.py
|
||||
.ruff_cache
|
||||
.github/workflows/
|
||||
@@ -1,3 +0,0 @@
|
||||
[settings]
|
||||
profile=black
|
||||
line_length = 250
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y postgresql-client locales && \
|
||||
pip install --upgrade pip && pip install -r requirements.txt && \
|
||||
sed -i '/ru_RU.UTF-8/s/^# //g' /etc/locale.gen && \
|
||||
locale-gen ru_RU.UTF-8
|
||||
|
||||
ENV LANG=ru_RU.UTF-8
|
||||
ENV LANGUAGE=ru_RU:ru
|
||||
ENV LC_ALL=ru_RU.UTF-8
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN sed -i "s|DATABASE_URL = .*|DATABASE_URL = '${DATABASE_URL}'|" config.py
|
||||
|
||||
CMD ["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,2 +1,7 @@
|
||||
formatting:
|
||||
@black . && isort . && flake8
|
||||
@echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
|
||||
@echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
|
||||
|
||||
lint:
|
||||
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
@@ -1,148 +1,106 @@
|
||||
# 🚀 SoloBot
|
||||
|
||||
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
|
||||
**SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
|
||||
|
||||
Три версии — море возможностей:
|
||||
- v1.4 — бот для продажи ключей vless:
|
||||
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
|
||||
|
||||

|
||||
- v2.3.1 — стабильная версия подписок вместо ключей с кнопками автодобавления в приложение:
|
||||
# Описание
|
||||
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
|
||||
|
||||

|
||||
- v3.0.4_beta — полноценные подписки с выбором серверов из клиентского приложения (не в боте):
|
||||
# Основные возможности
|
||||
|
||||
**SoloBot** реализует множество функций, включая:
|
||||
|
||||
### Управление подписками
|
||||
- Выдача подписок на различные периоды (**1 месяц, 3 месяца, полгода, год**).
|
||||
- Использование **тестового периода** подписки.
|
||||
- Продление ключей на указанные периоды.
|
||||
|
||||
### Полный контроль клиента над своими ключами
|
||||
- Просмотр информации о ключе (**сервер, оставшееся время, сам ключ**).
|
||||
- Продление ключей.
|
||||
- Удаление ключей.
|
||||
- Просмотр полной информации вплоть до минут истечения
|
||||
- Смена локации (**перемещение ключа между серверами**).
|
||||
- Поддержка **нескольких ключей и устройств** для одного клиента.
|
||||
|
||||
### Реферальная программа
|
||||
- Уникальная ссылка для приглашений.
|
||||
- Инлайн режим или обычные сообщения
|
||||
|
||||
### Интеграция платёжных систем
|
||||
В боте реализована интеграция касс как для физических лиц, так и для самозанятых и ИП:
|
||||
- **Юкасса** (Самозанятость и ИП)
|
||||
- **Юмани** (Физические Лица) (TrackLine)
|
||||
- **Robokassa** (Физические Лица)
|
||||
- **Cryptobot** (Криптовалюта) (izzzzzi)
|
||||
- **Звезды Telegram**
|
||||
|
||||
### Безопасность и стабильность
|
||||
- **Периодические бэкапы** базы данных клиентов и их восстановление.
|
||||
- **Смена домена** в случае переезда.
|
||||
|
||||
### Уведомления
|
||||
- **Произвольные сообщения** через панель администратора.
|
||||
- Уведомления о **неиспользованных пробниках**.
|
||||
- Уведомления об **истекающих ключах** (_за сутки, за 6 часов и в момент истечения_).
|
||||
- **Воронка продаж**. Если клиент не взял пробник предлагаются более выгодные условия.
|
||||
- **Рассылка лично или всем.** С сохранением форматирования и отправкой картинок
|
||||
|
||||
### Серверы
|
||||
- **Мультисерверность** — добавление сервера прямо в админке.
|
||||
- **Проверка на доступность** — бот следит за серверами и их откликом.
|
||||
- **Балансировка** — бот выдает подписку на наименее загруженный сервер.
|
||||
- **Сихронизация** — перемещайте клиентов на другие сервера бесшовно.
|
||||
|
||||
### Удобная админ-панель
|
||||
- **Поиск клиента** — по айди, нику или ключу.
|
||||
- **Управление подпиской клиента** — перевыпуск, изменение баланса и остальное.
|
||||
- **Управление ботом** — перезагрузка, списки банов, смена домена.
|
||||
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
|
||||
|
||||
|
||||
<img src=".github/images/preview_2.gif" alt="SoloBot" width="1600">
|
||||
|
||||
Если вам не хватает функций — направьте их в issue, мы реализуем
|
||||
### Кастомизация
|
||||
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
|
||||
- **Реферальная программа** — процент с каждого пополнения или фиксированная сумма.
|
||||
- **Все меню** — меняйте все меню как вам захочется, описания и тексты.
|
||||
- **Кешбэк** — начисляйте кэшбек за пополнения, если хотите.
|
||||
- **Настройки** — капча, подписка на канал, купоны и прочее.
|
||||
- **Автоматика** — удалять ли ключи после продления, продлевать ли их и остальное.
|
||||
|
||||
## 📋 Оглавление
|
||||
1. [Описание](#описание)
|
||||
2. [Стек технологий](#стек-технологий)
|
||||
3. [Установка](#установка)
|
||||
4. [Конфигурация](#конфигурация)
|
||||
5. [Запуск](#запуск)
|
||||
6. [Контакты](#контакты)
|
||||
|
||||
---
|
||||
|
||||
## 📖 Описание
|
||||
|
||||
SoloBot реализует множество функций, включая:
|
||||
### *Настраивайте как вам удобно!*
|
||||
|
||||
|
||||
- **Выдача ключей на пробный период**.
|
||||
- **Выдача ключей на длительные периоды** (1 месяц, 3 месяца, полгода, год).
|
||||
- **Продление ключей** на указанные периоды.
|
||||
- Полный контроль клиента над своими ключами:
|
||||
- Удаление ключей.
|
||||
- Продление ключей.
|
||||
- Просмотр информации о ключе (сервер, оставшееся время, сам ключ).
|
||||
- **Смена локации** (перемещение ключа между серверами).
|
||||
- Поддержка нескольких ключей для одного клиента (несколько устройств).
|
||||
- **Реферальная программа** с пригласительной ссылкой.
|
||||
- Доступ к **инструкциям**.
|
||||
- **Пополнение баланса**:
|
||||
* через Юкасса (самозанятость и ИП)
|
||||
* через freekassa (Физические Лица)
|
||||
* Криптовалюта (Thanks [izzzzzi](https://github.com/izzzzzi))
|
||||
- Периодические **бэкапы базы данных клиентов**.
|
||||
- Уведомления:
|
||||
- Произвольные сообщения через админку.
|
||||
- Уведомления о неиспользованных пробниках.
|
||||
- Уведомления о истекающих ключах (за сутки, за 6 часов и в момент истечения).
|
||||
- **Чат поддержки** и канал для связи.
|
||||
- **Автоматическое продление ключа** при наличии достаточного баланса.
|
||||
- **Удобная админка прямо в боте**
|
||||
- **мультисерверность** добавляй сервера в конфиг, и они автоматически будут в боте
|
||||
## Список версий
|
||||
|
||||
#### Бот постоянно развивается, обрастая новыми возможностями.
|
||||
#### Для полного рассмотрения каждой версии ознакомьтесь с [**релизами**](https://github.com/Vladless/Solo_bot/releases).
|
||||
|
||||
|
||||
---
|
||||
## 🔗 SoloBot в Telegram и Полная версия
|
||||
|
||||
## 💻 Стек технологий
|
||||
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
|
||||
|
||||
Проект использует следующие технологии:
|
||||
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
|
||||

|
||||
|
||||
- **Python** версии 3.8 или выше.
|
||||
- **Git** для клонирования репозитория.
|
||||
- **Virtualenv** для создания виртуального окружения (рекомендуется).
|
||||
- **PostgreSQL** для хранения данных.
|
||||
- **Nginx** для работы с вебхуками.
|
||||
- **aiogram** (версия 3.13) для взаимодействия с Telegram API.
|
||||
- **youkassa** для обработки платежей.
|
||||
- **aiohttp** для API запросов к панелям.
|
||||
|
||||
Проект полностью асинхронный, что обеспечивает высокую скорость работы.
|
||||
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
|
||||
|
||||
---
|
||||
#### Отзывы пользователей:
|
||||
SoloBot уже помог сотням пользователей в нашем сообществе:
|
||||

|
||||
|
||||
## ⚙️ Установка
|
||||
|
||||
### 1️⃣ Шаг 1: Клонирование репозитория
|
||||
**Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
|
||||
|
||||
Клонируйте репозиторий и перейдите в его директорию:
|
||||
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
|
||||
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
|
||||
|
||||
```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).
|
||||
|
||||
Вы также можете поддержать автора или купить полную версию бота с сопровождением по установке в поддержке бота!
|
||||
Полная пошаговая инстуркция по запуску бота предоставляется в поддержке бота
|
||||
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
username TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
language_code TEXT,
|
||||
is_bot BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
balance REAL NOT NULL DEFAULT 0.0,
|
||||
trial INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'balance'
|
||||
) THEN
|
||||
ALTER TABLE users ADD COLUMN balance REAL NOT NULL DEFAULT 0.0;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'trial'
|
||||
) THEN
|
||||
ALTER TABLE users ADD COLUMN trial INTEGER NOT NULL DEFAULT 0;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
tg_id BIGINT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
payment_system TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'success',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tg_id) REFERENCES users (tg_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keys
|
||||
(
|
||||
tg_id BIGINT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
expiry_time BIGINT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL DEFAULT 'cluster1',
|
||||
notified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (tg_id, client_id)
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'key' AND is_nullable = 'NO'
|
||||
) THEN
|
||||
ALTER TABLE keys ALTER COLUMN key DROP NOT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'remnawave_link'
|
||||
) THEN
|
||||
ALTER TABLE keys ADD COLUMN remnawave_link TEXT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'is_frozen'
|
||||
) THEN
|
||||
ALTER TABLE keys ADD COLUMN is_frozen BOOLEAN DEFAULT FALSE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'keys' AND column_name = 'alias'
|
||||
) THEN
|
||||
ALTER TABLE keys ADD COLUMN alias TEXT;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referrals
|
||||
(
|
||||
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
referrer_tg_id BIGINT NOT NULL,
|
||||
reward_issued BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupons
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
days INTEGER CHECK (days > 0 OR days IS NULL),
|
||||
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'coupons' AND column_name = 'days'
|
||||
) THEN
|
||||
ALTER TABLE coupons ADD COLUMN days INTEGER CHECK (days > 0 OR days IS NULL);
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupon_usages
|
||||
(
|
||||
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL,
|
||||
used_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (coupon_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications
|
||||
(
|
||||
tg_id BIGINT NOT NULL,
|
||||
last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
notification_type TEXT NOT NULL,
|
||||
PRIMARY KEY (tg_id, notification_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
cluster_name TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
subscription_url TEXT,
|
||||
inbound_id TEXT NOT NULL,
|
||||
panel_type TEXT NOT NULL DEFAULT '3x-ui',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
max_keys INTEGER,
|
||||
UNIQUE (cluster_name, server_name)
|
||||
);
|
||||
|
||||
ALTER TABLE servers ADD COLUMN IF NOT EXISTS panel_type TEXT NOT NULL DEFAULT '3x-ui';
|
||||
ALTER TABLE servers
|
||||
ALTER COLUMN subscription_url DROP NOT NULL;
|
||||
ALTER TABLE servers
|
||||
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
ALTER TABLE servers ADD COLUMN IF NOT EXISTS max_keys INTEGER;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gifts
|
||||
(
|
||||
gift_id TEXT PRIMARY KEY NOT NULL,
|
||||
sender_tg_id BIGINT NOT NULL,
|
||||
selected_months INTEGER NOT NULL,
|
||||
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
gift_link TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recipient_tg_id BIGINT,
|
||||
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
|
||||
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS temporary_data (
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocked_users (
|
||||
tg_id BIGINT PRIMARY KEY,
|
||||
blocked_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracking_sources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by BIGINT,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS source_code TEXT REFERENCES tracking_sources (code);
|
||||
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'connections'
|
||||
) THEN
|
||||
EXECUTE $upd$
|
||||
UPDATE users
|
||||
SET balance = c.balance,
|
||||
trial = c.trial
|
||||
FROM connections c
|
||||
WHERE users.tg_id = c.tg_id;
|
||||
$upd$;
|
||||
|
||||
EXECUTE 'DROP TABLE connections';
|
||||
END IF;
|
||||
END$$;
|
||||
@@ -1,64 +1,158 @@
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER
|
||||
from bot import bot
|
||||
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def backup_database():
|
||||
from bot import bot
|
||||
async def backup_database() -> Exception | None:
|
||||
"""
|
||||
Создает резервную копию базы данных и отправляет ее администраторам.
|
||||
|
||||
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"
|
||||
Returns:
|
||||
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
|
||||
"""
|
||||
backup_file_path, exception = _create_database_backup()
|
||||
|
||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||
if exception:
|
||||
logger.error(f"Ошибка при создании бэкапа базы данных: {exception}")
|
||||
return exception
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["pg_dump", "-U", USER, "-h", HOST, "-F", "c", "-f", BACKUP_FILE, DB_NAME],
|
||||
check=True,
|
||||
)
|
||||
logger.info(f"Бэкап базы данных создан: {BACKUP_FILE}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Ошибка при создании бэкапа базы данных: {e}")
|
||||
return
|
||||
await _send_backup_to_admins(backup_file_path)
|
||||
exception = _cleanup_old_backups()
|
||||
|
||||
try:
|
||||
with open(BACKUP_FILE, "rb") as backup_file:
|
||||
backup_input_file = BufferedInputFile(
|
||||
backup_file.read(), filename=os.path.basename(BACKUP_FILE)
|
||||
)
|
||||
await bot.send_document(ADMIN_ID, backup_input_file)
|
||||
logger.info(f"Бэкап базы данных отправлен админу: {ADMIN_ID}")
|
||||
if exception:
|
||||
logger.error(f"Ошибка при удалении старых бэкапов базы данных: {exception}")
|
||||
return exception
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
|
||||
logger.error(f"Ошибка при отправке бэкапа базы данных: {e}")
|
||||
return e
|
||||
|
||||
|
||||
def _create_database_backup() -> tuple[str | None, Exception | None]:
|
||||
"""
|
||||
Создает резервную копию базы данных PostgreSQL.
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[str], Optional[Exception]]: Путь к файлу бэкапа и исключение (если произошла ошибка)
|
||||
"""
|
||||
date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
|
||||
backup_dir = Path(BACK_DIR)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}.sql"
|
||||
|
||||
try:
|
||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"find",
|
||||
BACKUP_DIR,
|
||||
"-type",
|
||||
"f",
|
||||
"-name",
|
||||
"*.sql",
|
||||
"-mtime",
|
||||
"+3",
|
||||
"-exec",
|
||||
"rm",
|
||||
"{}",
|
||||
";",
|
||||
"pg_dump",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
PG_HOST,
|
||||
"-p",
|
||||
PG_PORT,
|
||||
"-F",
|
||||
"c",
|
||||
"-f",
|
||||
str(filename),
|
||||
DB_NAME,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
logger.info("Старые бэкапы удалены.")
|
||||
logger.info(f"Бэкап базы данных создан: {filename}")
|
||||
return str(filename), None
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
|
||||
logger.error(f"Ошибка при выполнении pg_dump: {e.stderr}")
|
||||
return None, e
|
||||
except Exception as e:
|
||||
logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
|
||||
return None, e
|
||||
finally:
|
||||
if "PGPASSWORD" in os.environ:
|
||||
del os.environ["PGPASSWORD"]
|
||||
|
||||
del os.environ["PGPASSWORD"]
|
||||
|
||||
def _cleanup_old_backups() -> Exception | None:
|
||||
"""
|
||||
Удаляет бэкапы старше 3 дней.
|
||||
|
||||
Returns:
|
||||
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
|
||||
"""
|
||||
try:
|
||||
backup_dir = Path(BACK_DIR)
|
||||
if not backup_dir.exists():
|
||||
return None
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=3)
|
||||
|
||||
for backup_file in backup_dir.glob("*.sql"):
|
||||
if backup_file.is_file():
|
||||
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
||||
if file_mtime < cutoff_date:
|
||||
backup_file.unlink()
|
||||
logger.info(f"Удален старый бэкап: {backup_file}")
|
||||
|
||||
logger.info("Очистка старых бэкапов завершена")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
|
||||
return e
|
||||
|
||||
|
||||
async def create_backup_and_send_to_admins(client) -> None:
|
||||
"""
|
||||
Создает бэкап и отправляет администраторам через переданный клиент.
|
||||
|
||||
Args:
|
||||
client: Клиент для работы с базой данных
|
||||
"""
|
||||
await client.login()
|
||||
await client.database.export()
|
||||
|
||||
|
||||
async def _send_backup_to_admins(backup_file_path: str) -> None:
|
||||
"""
|
||||
Отправляет файл бэкапа всем администраторам через Telegram.
|
||||
|
||||
Args:
|
||||
backup_file_path: Путь к файлу бэкапа
|
||||
|
||||
Raises:
|
||||
Exception: При ошибке отправки файла
|
||||
"""
|
||||
if not backup_file_path or not os.path.exists(backup_file_path):
|
||||
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
|
||||
|
||||
try:
|
||||
async with aiofiles.open(backup_file_path, "rb") as backup_file:
|
||||
backup_data = await backup_file.read()
|
||||
filename = os.path.basename(backup_file_path)
|
||||
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
|
||||
|
||||
for admin_id in ADMIN_ID:
|
||||
try:
|
||||
await bot.send_document(chat_id=admin_id, document=backup_input_file)
|
||||
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
|
||||
raise
|
||||
|
||||
@@ -1,49 +1,92 @@
|
||||
from aiogram import Bot, Dispatcher, Router
|
||||
import traceback
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import ExceptionTypeFilter
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.types import BufferedInputFile, ErrorEvent
|
||||
from aiogram.utils.markdown import hbold
|
||||
|
||||
from config import API_TOKEN, CRYPTO_BOT_ENABLE, FREEKASSA_ENABLE, ROBOKASSA_ENABLE, STARS_ENABLE, YOOKASSA_ENABLE
|
||||
from middlewares.database import DatabaseMiddleware
|
||||
from middlewares.logging import LoggingMiddleware
|
||||
from config import ADMIN_ID, API_TOKEN
|
||||
from filters.private import IsPrivateFilter
|
||||
from logger import logger
|
||||
from middlewares import register_middleware
|
||||
|
||||
bot = Bot(token=API_TOKEN)
|
||||
|
||||
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
router = Router()
|
||||
|
||||
from handlers import commands, coupons, donate, notifications, pay, profile, start
|
||||
from handlers.admin import admin_commands, admin_coupons, admin_panel, 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
|
||||
version = "4.2"
|
||||
|
||||
dp.include_router(admin_commands.router)
|
||||
dp.include_router(admin_coupons.router)
|
||||
dp.include_router(admin_panel.router)
|
||||
dp.include_router(admin_user_editor.router)
|
||||
dp.include_router(commands.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)
|
||||
register_middleware(dp)
|
||||
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.filter(IsPrivateFilter())
|
||||
dp.callback_query.filter(IsPrivateFilter())
|
||||
|
||||
|
||||
dp.message.middleware(DatabaseMiddleware())
|
||||
dp.callback_query.middleware(DatabaseMiddleware())
|
||||
@dp.errors(ExceptionTypeFilter(Exception))
|
||||
async def errors_handler(
|
||||
event: ErrorEvent,
|
||||
bot: Bot,
|
||||
) -> bool:
|
||||
if isinstance(event.exception, TelegramForbiddenError):
|
||||
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
|
||||
return True
|
||||
|
||||
if isinstance(event.exception, TelegramBadRequest):
|
||||
error_message = str(event.exception)
|
||||
|
||||
if (
|
||||
"query is too old and response timeout expired or query ID is invalid" in error_message
|
||||
or "message can't be deleted for everyone" in error_message
|
||||
or "message to delete not found" in error_message
|
||||
):
|
||||
logger.warning("Отправляем стартовое меню.")
|
||||
|
||||
try:
|
||||
from handlers.start import handle_start_callback_query, start_command
|
||||
|
||||
if event.update.message:
|
||||
await start_command(
|
||||
event.update.message, state=dp.storage, session=None, admin=False, captcha=False
|
||||
)
|
||||
elif event.update.callback_query:
|
||||
await handle_start_callback_query(
|
||||
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
|
||||
|
||||
return True
|
||||
logger.exception(f"Update: {event.update}\nException: {event.exception}")
|
||||
if not ADMIN_ID:
|
||||
return True
|
||||
try:
|
||||
for admin_id in ADMIN_ID:
|
||||
await bot.send_document(
|
||||
chat_id=admin_id,
|
||||
document=BufferedInputFile(
|
||||
traceback.format_exc().encode(),
|
||||
filename=f"error_{event.update.update_id}.txt",
|
||||
),
|
||||
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
|
||||
)
|
||||
try:
|
||||
from handlers.start import handle_start_callback_query, start_command
|
||||
|
||||
if event.update.message:
|
||||
await start_command(event.update.message, state=dp.storage, session=None, admin=False, captcha=False)
|
||||
elif event.update.callback_query:
|
||||
await handle_start_callback_query(
|
||||
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
|
||||
except TelegramBadRequest as exception:
|
||||
logger.warning(f"Failed to send error details: {exception}")
|
||||
except Exception as exception:
|
||||
logger.error(f"Unexpected error in error handler: {exception}")
|
||||
return True
|
||||
|
||||
Executable
+402
@@ -0,0 +1,402 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
from rich.console import Console
|
||||
from rich.prompt import Confirm, Prompt
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from config import BOT_SERVICE
|
||||
|
||||
|
||||
try:
|
||||
sys.stdin.reconfigure(encoding="utf-8")
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not os.environ.get("LC_ALL", "").endswith("UTF-8"):
|
||||
os.environ["LC_ALL"] = "en_US.UTF-8"
|
||||
os.environ["LANG"] = "en_US.UTF-8"
|
||||
|
||||
console = Console()
|
||||
|
||||
BACK_DIR = os.path.expanduser("~/.solobot_backup")
|
||||
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
|
||||
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
IS_ROOT_DIR = PROJECT_DIR == '/root'
|
||||
|
||||
if IS_ROOT_DIR:
|
||||
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
|
||||
console.print("[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]")
|
||||
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
|
||||
console.print("[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]")
|
||||
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
|
||||
sys.exit(1)
|
||||
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
|
||||
SERVICE_NAME = BOT_SERVICE
|
||||
|
||||
|
||||
def is_service_exists(service_name):
|
||||
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
|
||||
return service_name in result.stdout
|
||||
|
||||
|
||||
def print_logo():
|
||||
logo = Text(
|
||||
"""
|
||||
███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗
|
||||
██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝
|
||||
███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║
|
||||
╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║
|
||||
███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║
|
||||
╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝
|
||||
""",
|
||||
style="bold cyan",
|
||||
)
|
||||
console.print(logo)
|
||||
|
||||
|
||||
def backup_project():
|
||||
console.print("[yellow]📦 Создаётся резервная копия проекта...[/yellow]")
|
||||
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
|
||||
subprocess.run(["rm", "-rf", BACK_DIR])
|
||||
subprocess.run(["cp", "-r", PROJECT_DIR, BACK_DIR])
|
||||
console.print(f"[green]✅ Бэкап сохранён в: {BACK_DIR}[/green]")
|
||||
|
||||
|
||||
def fix_permissions():
|
||||
"""Устанавливает корректные права на файлы проекта"""
|
||||
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
|
||||
try:
|
||||
user = os.getenv('SUDO_USER') or os.getenv('USER')
|
||||
if user:
|
||||
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
|
||||
|
||||
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
|
||||
|
||||
console.print("[green]✅ Права успешно установлены[/green]")
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
|
||||
|
||||
|
||||
def install_rsync_if_needed():
|
||||
if subprocess.run(["which", "rsync"], capture_output=True).returncode != 0:
|
||||
console.print("[blue]📦 Установка rsync...[/blue]")
|
||||
os.system("sudo apt update && sudo apt install -y rsync")
|
||||
|
||||
|
||||
def clean_project_dir_safe(update_buttons=False):
|
||||
console.print("[yellow]🧹 Очистка проекта перед обновлением...[/yellow]")
|
||||
preserved_paths = {
|
||||
os.path.join(PROJECT_DIR, "config.py"),
|
||||
os.path.join(PROJECT_DIR, "handlers", "buttons.py"),
|
||||
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
|
||||
os.path.join(PROJECT_DIR, "img"),
|
||||
}
|
||||
|
||||
if not update_buttons:
|
||||
preserved_paths.add(os.path.join(PROJECT_DIR, "handlers", "buttons.py"))
|
||||
|
||||
for root, dirs, files in os.walk(PROJECT_DIR, topdown=False):
|
||||
for file in files:
|
||||
path = os.path.join(root, file)
|
||||
if path in preserved_paths:
|
||||
continue
|
||||
try:
|
||||
os.remove(path)
|
||||
except PermissionError:
|
||||
subprocess.run(["sudo", "rm", "-f", path])
|
||||
except Exception as e:
|
||||
console.print(f"[red]Не удалось удалить файл: {path}: {e}[/red]")
|
||||
|
||||
for dir in dirs:
|
||||
dir_path = os.path.join(root, dir)
|
||||
if os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "handlers"):
|
||||
continue
|
||||
try:
|
||||
os.rmdir(dir_path)
|
||||
except Exception:
|
||||
subprocess.run(["sudo", "rm", "-rf", dir_path])
|
||||
|
||||
|
||||
def install_git_if_needed():
|
||||
if subprocess.run(["which", "git"], capture_output=True).returncode != 0:
|
||||
console.print("[blue]Установка Git...[/blue]")
|
||||
os.system("sudo apt update && sudo apt install -y git")
|
||||
|
||||
|
||||
def install_dependencies():
|
||||
console.print("[blue]🔧 Установка зависимостей...[/blue]")
|
||||
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
|
||||
try:
|
||||
if not os.path.exists("venv"):
|
||||
console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]")
|
||||
subprocess.run("python3 -m venv venv", shell=True, check=True)
|
||||
|
||||
subprocess.run(
|
||||
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
|
||||
|
||||
|
||||
def restart_service():
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
console.print("[blue]🚀 Перезапуск службы...[/blue]")
|
||||
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
|
||||
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
|
||||
|
||||
def get_local_version():
|
||||
path = os.path.join(PROJECT_DIR, "bot.py")
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def get_remote_version(branch="main"):
|
||||
try:
|
||||
url = f"https://raw.githubusercontent.com/Vladless/Solo_bot/{branch}/bot.py"
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
for line in response.text.splitlines():
|
||||
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def update_from_beta():
|
||||
update_cli_launcher()
|
||||
local_version = get_local_version()
|
||||
remote_version = get_remote_version(branch="dev")
|
||||
|
||||
if local_version and remote_version:
|
||||
console.print(f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
|
||||
if local_version == remote_version:
|
||||
if not Confirm.ask("[yellow]❗ Версия актуальна. Обновить всё равно?[/yellow]"):
|
||||
return
|
||||
else:
|
||||
console.print("[red]⚠️ Не удалось определить версии.[/red]")
|
||||
|
||||
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
|
||||
return
|
||||
|
||||
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
|
||||
if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
|
||||
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 -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)
|
||||
|
||||
exclude_options = "--exclude=img"
|
||||
if not update_buttons:
|
||||
exclude_options += " --exclude=handlers/buttons.py"
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
fix_permissions()
|
||||
install_dependencies()
|
||||
restart_service()
|
||||
console.print("[green]✅ Обновление с ветки dev завершено.[/green]")
|
||||
|
||||
|
||||
def update_from_release():
|
||||
update_cli_launcher()
|
||||
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
|
||||
return
|
||||
|
||||
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
|
||||
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
|
||||
if not Confirm.ask("[red]❓ Вы точно хотите продолжить?[/red]"):
|
||||
return
|
||||
|
||||
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
|
||||
|
||||
backup_project()
|
||||
install_git_if_needed()
|
||||
install_rsync_if_needed()
|
||||
|
||||
try:
|
||||
response = requests.get("https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10)
|
||||
releases = response.json()[:3]
|
||||
tag_choices = [r["tag_name"] for r in releases]
|
||||
|
||||
if not tag_choices:
|
||||
raise ValueError("Не удалось получить список релизов")
|
||||
|
||||
console.print("\n[bold green]Доступные релизы:[/bold green]")
|
||||
for idx, tag in enumerate(tag_choices, 1):
|
||||
console.print(f"[cyan]{idx}.[/cyan] {tag}")
|
||||
|
||||
selected = Prompt.ask(
|
||||
"[bold blue]Выберите номер релиза[/bold blue]",
|
||||
choices=[str(i) for i in range(1, len(tag_choices) + 1)]
|
||||
)
|
||||
tag_name = tag_choices[int(selected) - 1]
|
||||
|
||||
if not Confirm.ask(f"[yellow]🔁 Подтвердите установку релиза {tag_name}[/yellow]"):
|
||||
return
|
||||
|
||||
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
subprocess.run(f"git clone --depth 1 --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}", shell=True, check=True)
|
||||
|
||||
console.print("[red]⚠️ Начинается перезапись файлов бота![/red]")
|
||||
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
|
||||
clean_project_dir_safe(update_buttons=update_buttons)
|
||||
|
||||
exclude_options = "--exclude=img"
|
||||
if not update_buttons:
|
||||
exclude_options += " --exclude=handlers/buttons.py"
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
fix_permissions()
|
||||
install_dependencies()
|
||||
restart_service()
|
||||
console.print(f"[green]✅ Обновление до релиза {tag_name} завершено.[/green]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
|
||||
|
||||
|
||||
def show_update_menu():
|
||||
|
||||
if IS_ROOT_DIR:
|
||||
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
|
||||
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title="Выберите способ обновления", title_style="bold green")
|
||||
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||
table.add_column("Источник", style="white")
|
||||
table.add_row("1", "Обновить до BETA")
|
||||
table.add_row("2", "Обновить до последнего релиза")
|
||||
table.add_row("3", "Назад в меню")
|
||||
|
||||
console.print(table)
|
||||
choice = Prompt.ask("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
|
||||
|
||||
if choice == "1":
|
||||
update_from_beta()
|
||||
elif choice == "2":
|
||||
update_from_release()
|
||||
|
||||
|
||||
def show_menu():
|
||||
table = Table(title="Solobot CLI v0.1.7", title_style="bold magenta", header_style="bold blue")
|
||||
table.add_column("№", justify="center", style="cyan", no_wrap=True)
|
||||
table.add_column("Операция", style="white")
|
||||
table.add_row("1", "Запустить бота (systemd)")
|
||||
table.add_row("2", "Запустить напрямую: venv/bin/python main.py")
|
||||
table.add_row("3", "Перезапустить бота (systemd)")
|
||||
table.add_row("4", "Остановить бота (systemd)")
|
||||
table.add_row("5", "Показать логи (80 строк)")
|
||||
table.add_row("6", "Показать статус")
|
||||
table.add_row("7", "Обновить Solobot")
|
||||
table.add_row("8", "Обновить CLI лаунчер")
|
||||
table.add_row("9", "Выход")
|
||||
console.print(table)
|
||||
|
||||
|
||||
def update_cli_launcher():
|
||||
"""Обновляет CLI лаунчер с dev ветки"""
|
||||
console.print("[yellow]🔄 Обновление CLI лаунчера...[/yellow]")
|
||||
try:
|
||||
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
|
||||
response = requests.get(url, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), 'w', encoding='utf-8') as f:
|
||||
f.write(response.text)
|
||||
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
|
||||
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
|
||||
else:
|
||||
console.print("[red]❌ Не удалось загрузить новый CLI[/red]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Ошибка при обновлении CLI: {e}[/red]")
|
||||
|
||||
|
||||
def main():
|
||||
os.chdir(PROJECT_DIR)
|
||||
print_logo()
|
||||
try:
|
||||
while True:
|
||||
show_menu()
|
||||
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 10)])
|
||||
if choice == "1":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
subprocess.run(["sudo", "systemctl", "start", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "2":
|
||||
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
|
||||
subprocess.run(["venv/bin/python", "main.py"])
|
||||
elif choice == "3":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
|
||||
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "4":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
|
||||
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "5":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
subprocess.run(["sudo", "journalctl", "-u", SERVICE_NAME, "-n", "80", "--no-pager"])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "6":
|
||||
if is_service_exists(SERVICE_NAME):
|
||||
subprocess.run(["sudo", "systemctl", "status", SERVICE_NAME])
|
||||
else:
|
||||
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
|
||||
elif choice == "7":
|
||||
show_update_menu()
|
||||
elif choice == "8":
|
||||
if Confirm.ask("[yellow]Обновить CLI лаунчер с dev ветки?[/yellow]"):
|
||||
update_cli_launcher()
|
||||
elif choice == "9":
|
||||
if Confirm.ask("[yellow]Хотите обновить CLI перед выходом?[/yellow]"):
|
||||
update_cli_launcher()
|
||||
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[bold red]⏹ Прерывание. Выход из CLI.[/bold red]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,112 +0,0 @@
|
||||
import py3xui
|
||||
|
||||
from config import TOTAL_GB
|
||||
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,
|
||||
):
|
||||
"""
|
||||
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(1, [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, 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
|
||||
|
||||
await xui.client.update(client.id, client)
|
||||
logger.info(
|
||||
f"Ключ клиента {client.email} успешно продлён до {new_expiry_time}."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обновлении клиента с email {email}: {e}")
|
||||
|
||||
|
||||
async def delete_client(
|
||||
xui,
|
||||
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
|
||||
inbound_id = 1
|
||||
|
||||
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
|
||||
+1375
-314
File diff suppressed because it is too large
Load Diff
+2
-4
@@ -6,9 +6,7 @@ from config import ADMIN_ID
|
||||
|
||||
class IsAdminFilter(BaseFilter):
|
||||
async def __call__(self, message: Message) -> bool:
|
||||
if isinstance(ADMIN_ID, list):
|
||||
try:
|
||||
return message.from_user.id in ADMIN_ID
|
||||
elif isinstance(ADMIN_ID, int):
|
||||
return message.from_user.id == ADMIN_ID
|
||||
else:
|
||||
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 .pay import router as pay_router
|
||||
from .payments import router as payments_router
|
||||
from .profile import router as profile_router
|
||||
from .refferal import router as refferal_router
|
||||
from .start import router as start_router
|
||||
|
||||
|
||||
router = Router(name="handlers_main_router")
|
||||
|
||||
router.include_routers(
|
||||
start_router,
|
||||
captcha_router,
|
||||
profile_router,
|
||||
pay_router,
|
||||
donate_router,
|
||||
coupons_router,
|
||||
notifications_router,
|
||||
payments_router,
|
||||
keys_router,
|
||||
instructions_router,
|
||||
admin_router,
|
||||
refferal_router,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .ads import router as ads_router
|
||||
from .backups import router as backups_router
|
||||
from .bans import router as bans_router
|
||||
from .clusters import router as clusters_router
|
||||
from .coupons import router as coupons_router
|
||||
from .management import router as management_router
|
||||
from .panel import router as panel_router
|
||||
from .restart import router as restart_router
|
||||
from .sender import router as sender_router
|
||||
from .servers import router as servers_router
|
||||
from .stats import router as stats_router
|
||||
from .users import router as users_router
|
||||
|
||||
|
||||
router = Router(name="admins_main_router")
|
||||
|
||||
router.include_routers(
|
||||
panel_router,
|
||||
management_router,
|
||||
servers_router,
|
||||
clusters_router,
|
||||
users_router,
|
||||
stats_router,
|
||||
backups_router,
|
||||
sender_router,
|
||||
coupons_router,
|
||||
restart_router,
|
||||
bans_router,
|
||||
ads_router,
|
||||
)
|
||||
@@ -1,160 +0,0 @@
|
||||
import asyncpg
|
||||
from aiogram import Router, types
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from bot import bot
|
||||
from config import DATABASE_URL
|
||||
from database import add_balance_to_client, check_connection_exists
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.texts import TRIAL
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class Form(StatesGroup):
|
||||
waiting_for_server_selection = State()
|
||||
waiting_for_key_name = State()
|
||||
viewing_profile = State()
|
||||
waiting_for_message = State()
|
||||
|
||||
|
||||
@router.message(Command("add_balance"), IsAdminFilter())
|
||||
async def cmd_add_balance(message: types.Message):
|
||||
try:
|
||||
_, client_id, amount = message.text.split()
|
||||
amount = float(amount)
|
||||
|
||||
if not await check_connection_exists(int(client_id)):
|
||||
await message.reply(f"❌ Клиент с ID {client_id} не найден в базе данных.")
|
||||
return
|
||||
|
||||
await add_balance_to_client(int(client_id), amount)
|
||||
await message.reply(
|
||||
f"✅ Баланс клиента {client_id} успешно пополнен на {amount}"
|
||||
)
|
||||
except ValueError:
|
||||
await message.reply(
|
||||
"❓ Неверный формат команды!\n"
|
||||
"Пожалуйста, используйте следующий шаблон:\n"
|
||||
"/add_balance <ID клиента> <сумма пополнения>"
|
||||
)
|
||||
except Exception as e:
|
||||
await message.reply(f"🚨 Произошла непредвиденная ошибка: {e}")
|
||||
|
||||
|
||||
@router.message(Command("backup"), IsAdminFilter())
|
||||
async def backup_command(message: types.Message):
|
||||
from backup import backup_database
|
||||
|
||||
await message.answer("🔄 Инициализация резервного копирования базы данных...")
|
||||
await backup_database()
|
||||
await message.answer(
|
||||
"✅ Бэкап базы данных успешно завершен и отправлен администратору."
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("send_trial"), IsAdminFilter())
|
||||
async def handle_send_trial_command(message: types.Message, state: FSMContext):
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
records = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id FROM connections WHERE trial = 0
|
||||
"""
|
||||
)
|
||||
|
||||
if records:
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
blocked_count = 0
|
||||
|
||||
for record in records:
|
||||
tg_id = record["tg_id"]
|
||||
trial_message = TRIAL
|
||||
try:
|
||||
await bot.send_message(chat_id=tg_id, text=trial_message)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
if "Forbidden: bot was blocked by the user" in str(e):
|
||||
blocked_count += 1
|
||||
logger.info(
|
||||
f"🚫 Бот заблокирован пользователем с tg_id: {tg_id}"
|
||||
)
|
||||
else:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f"❌ Ошибка при отправке сообщения пользователю {tg_id}: {e}"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"📊 Результаты рассылки пробных периодов:\n"
|
||||
f"✅ Успешно отправлено: {success_count}\n"
|
||||
f"🚫 Заблокировано: {blocked_count}\n"
|
||||
f"❌ Ошибок: {error_count}"
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
"📭 Нет пользователей с неиспользованными пробными ключами."
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❗ Ошибка при отправке сообщений: {e}")
|
||||
|
||||
|
||||
@router.message(Command("send_to_all"), IsAdminFilter())
|
||||
async def send_message_to_all_clients(
|
||||
message: types.Message, state: FSMContext, from_panel=False
|
||||
):
|
||||
if from_panel:
|
||||
await message.answer(
|
||||
"✍️ Введите текст сообщения, который вы хотите отправить всем клиентам:"
|
||||
)
|
||||
await state.set_state(Form.waiting_for_message)
|
||||
|
||||
|
||||
@router.message(Form.waiting_for_message, IsAdminFilter())
|
||||
async def process_message_to_all(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
):
|
||||
text_message = message.text
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
tg_ids = await conn.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 message.answer("❌ Произошла ошибка при отправке сообщения.")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
await state.clear()
|
||||
@@ -1,207 +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
|
||||
|
||||
from database import create_coupon, delete_coupon_from_db, get_all_coupons
|
||||
from logger import logger
|
||||
|
||||
|
||||
class AdminCouponsState(StatesGroup):
|
||||
waiting_for_coupon_data = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupons_editor")
|
||||
async def show_coupon_management_menu(callback_query: types.CallbackQuery):
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="➕ Создать купон", callback_data="create_coupon")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="Купоны", callback_data="coupons"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_admin_menu")
|
||||
)
|
||||
|
||||
markup = builder.as_markup()
|
||||
await callback_query.message.answer(
|
||||
"🛠 Меню управления купонами:", reply_markup=markup
|
||||
)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupons")
|
||||
async def show_coupon_list(callback_query: types.CallbackQuery):
|
||||
try:
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
coupons = await get_all_coupons()
|
||||
|
||||
if not coupons:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
|
||||
)
|
||||
markup = builder.as_markup()
|
||||
|
||||
await callback_query.message.answer(
|
||||
"❌ На данный момент нет доступных купонов.\n"
|
||||
"Вы можете вернуться в меню управления.",
|
||||
parse_mode="HTML",
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback_query.answer()
|
||||
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")
|
||||
)
|
||||
|
||||
markup = builder.as_markup()
|
||||
await callback_query.message.answer(
|
||||
coupon_list, parse_mode="HTML", reply_markup=markup
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении списка купонов: {e}")
|
||||
await callback_query.message.answer(
|
||||
f"❌ Произошла ошибка при получении списка купонов: {e}", parse_mode="HTML"
|
||||
)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_coupon_"))
|
||||
async def handle_delete_coupon(callback_query: types.CallbackQuery):
|
||||
coupon_code = callback_query.data[len("delete_coupon_") :]
|
||||
|
||||
try:
|
||||
result = await delete_coupon_from_db(coupon_code)
|
||||
|
||||
if result:
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
await show_coupon_list(callback_query)
|
||||
else:
|
||||
await callback_query.message.answer(
|
||||
f"❌ Купон с кодом <b>{coupon_code}</b> не найден.", parse_mode="HTML"
|
||||
)
|
||||
await show_coupon_list(callback_query)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении купона: {e}")
|
||||
await callback_query.message.answer(
|
||||
f"❌ Произошла ошибка при удалении купона: {e}", parse_mode="HTML"
|
||||
)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "create_coupon")
|
||||
async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
await callback_query.message.answer(
|
||||
"<b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"<i>код</i> <i>сумма</i> <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b>\n\n",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(AdminCouponsState.waiting_for_coupon_data)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_coupon_data)
|
||||
async def process_coupon_data(message: types.Message, state: FSMContext):
|
||||
text = message.text.strip()
|
||||
|
||||
parts = text.split()
|
||||
if len(parts) != 3:
|
||||
await message.answer(
|
||||
"<b>❌ Некорректный формат!</b> Пожалуйста, введите данные в формате:\n"
|
||||
"<b>код</b> <b>сумма</b> <b>лимит</b>\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
coupon_code = parts[0]
|
||||
coupon_amount = float(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"<b>⚠️ Проверьте правильность введенных данных.</b>\n"
|
||||
"Сумма должна быть числом, а лимит — целым числом.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(coupon_code, coupon_amount, usage_limit)
|
||||
|
||||
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")
|
||||
)
|
||||
markup = builder.as_markup()
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
await message.answer(result_message, parse_mode="HTML", reply_markup=markup)
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer(
|
||||
f"<b>❌ Ошибка при создании купона:</b> {e}", parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "back_to_coupons_menu")
|
||||
async def back_to_coupons_menu(callback_query: types.CallbackQuery):
|
||||
"""Возвращаем пользователя в меню управления купонами"""
|
||||
await show_coupon_management_menu(callback_query)
|
||||
@@ -1,191 +0,0 @@
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
import asyncpg
|
||||
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 CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from backup import backup_database
|
||||
from bot import bot
|
||||
from config import DATABASE_URL
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.admin.admin_commands import send_message_to_all_clients
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
waiting_for_tg_id = State()
|
||||
displaying_user_info = State()
|
||||
|
||||
|
||||
@router.message(Command("admin"), IsAdminFilter())
|
||||
async def handle_admin_command(message: types.Message):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика пользователей", callback_data="user_stats"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="👥 Управление пользователями", callback_data="user_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")
|
||||
)
|
||||
await bot.send_message(
|
||||
message.chat.id, "🤖 Панель администратора", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "user_stats", IsAdminFilter())
|
||||
async def user_stats_menu(callback_query: CallbackQuery):
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
total_users = await conn.fetchval("SELECT COUNT(*) FROM connections")
|
||||
total_keys = await conn.fetchval("SELECT COUNT(*) FROM keys")
|
||||
total_referrals = await conn.fetchval("SELECT COUNT(*) FROM referrals")
|
||||
|
||||
active_keys = await conn.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"👤 Зарегистрированных пользователей: <b>{total_users}</b>\n"
|
||||
f"🔑 Всего сгенерированных ключей: <b>{total_keys}</b>\n"
|
||||
f"🤝 Привлеченных рефералов: <b>{total_referrals}</b>\n"
|
||||
f"✅ Действующих ключей: <b>{active_keys}</b>\n"
|
||||
f"❌ Просроченных ключей: <b>{expired_keys}</b>"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Вернуться в меню", callback_data="back_to_admin_menu"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
stats_message, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "send_to_alls", IsAdminFilter())
|
||||
async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext):
|
||||
await send_message_to_all_clients(callback_query.message, state, from_panel=True)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "backups", IsAdminFilter())
|
||||
async def handle_backup(message: Message):
|
||||
await message.answer("💾 Инициализация резервного копирования базы данных...")
|
||||
await backup_database()
|
||||
await message.answer(
|
||||
"✅ Резервная копия успешно создана и отправлена администратору."
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "restart_bot", IsAdminFilter())
|
||||
async def handle_restart(callback_query: CallbackQuery):
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "systemctl", "restart", "bot.service"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
await callback_query.message.answer("🔄 Бот успешно перезапущен.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
await callback_query.message.answer(
|
||||
f"⚠️ Перезагрузка бота будет выполнена через 30 секунд. Детали: {e.stderr}"
|
||||
)
|
||||
|
||||
|
||||
@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="🔙 Вернуться назад", callback_data="back_to_admin_menu"
|
||||
)
|
||||
)
|
||||
await callback_query.message.edit_text(
|
||||
"👇 Выберите способ поиска пользователя:", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "back_to_admin_menu", IsAdminFilter())
|
||||
async def back_to_admin_menu(callback_query: CallbackQuery):
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tg_id = callback_query.from_user.id
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика пользователей", callback_data="user_stats"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="👥 Управление пользователями", callback_data="user_editor"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📢 Массовая рассылка", callback_data="send_to_alls")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🎟️ Управление купонами", callback_data="coupons_editor"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="💾 Создать резервную копию", callback_data="backups")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot")
|
||||
)
|
||||
await bot.send_message(
|
||||
tg_id, "🤖 Панель администратора", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
async def handle_error(tg_id, callback_query, message):
|
||||
await bot.edit_message_text(
|
||||
message, chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
@@ -1,543 +0,0 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
import asyncpg
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import CLUSTERS, DATABASE_URL, TOTAL_GB
|
||||
from database import get_client_id_by_email, restore_trial, update_key_expiry
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.admin.admin_panel import back_to_admin_menu
|
||||
from handlers.keys.key_utils import delete_key_from_cluster, 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()
|
||||
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):
|
||||
await callback_query.message.edit_text("🔍 Введите Telegram ID клиента:")
|
||||
await state.set_state(UserEditorState.waiting_for_tg_id)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_tg_id, F.text.isdigit(), IsAdminFilter())
|
||||
async def handle_tg_id_input(message: types.Message, state: FSMContext):
|
||||
tg_id = int(message.text)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
balance = await conn.fetchval(
|
||||
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
|
||||
)
|
||||
key_records = await conn.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
|
||||
referral_count = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
|
||||
)
|
||||
|
||||
if balance is None:
|
||||
await message.reply("Пользователь с указанным tg_id не найден.")
|
||||
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="back_to_user_editor")
|
||||
)
|
||||
|
||||
user_info = (
|
||||
f"📊 Информация о пользователе:\n"
|
||||
f"💰 Баланс: <b>{balance}</b>\n"
|
||||
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
|
||||
f"🔑 Ключи (для редактирования нажмите на ключ):"
|
||||
)
|
||||
await message.reply(
|
||||
user_info, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(UserEditorState.displaying_user_info)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("restore_trial_"), IsAdminFilter())
|
||||
async def handle_restore_trial(callback_query: types.CallbackQuery):
|
||||
tg_id = int(callback_query.data.split("_")[2])
|
||||
|
||||
await restore_trial(tg_id)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в меню администратора", callback_data="back_to_user_editor"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
"✅ Триал успешно восстановлен.", 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)
|
||||
|
||||
await callback_query.message.edit_text("💸 Введите новую сумму баланса:")
|
||||
await callback_query.answer()
|
||||
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):
|
||||
if not message.text.isdigit() or int(message.text) < 0:
|
||||
await message.reply(
|
||||
"❌ Пожалуйста, введите корректную сумму для изменения баланса."
|
||||
)
|
||||
return
|
||||
|
||||
new_balance = int(message.text)
|
||||
user_data = await state.get_data()
|
||||
tg_id = user_data.get("tg_id")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
await conn.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="back_to_user_editor",
|
||||
)
|
||||
)
|
||||
await message.reply(
|
||||
response_message, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_key_"), IsAdminFilter())
|
||||
async def process_key_edit(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("_", 2)[2]
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow(
|
||||
"""
|
||||
SELECT k.key, k.expiry_time, k.server_id
|
||||
FROM keys k
|
||||
WHERE k.email = $1
|
||||
""",
|
||||
email,
|
||||
)
|
||||
|
||||
if record:
|
||||
key = record["key"]
|
||||
expiry_time = record["expiry_time"]
|
||||
server_id = record["server_id"]
|
||||
server_name = "Неизвестный сервер"
|
||||
|
||||
for cluster in CLUSTERS.values():
|
||||
if server_id in cluster:
|
||||
server_name = cluster[server_id].get(
|
||||
"name", "Неизвестный сервер"
|
||||
)
|
||||
break
|
||||
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "<b>Ключ истек.</b>"
|
||||
elif time_left.days > 0:
|
||||
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
|
||||
else:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
|
||||
|
||||
formatted_expiry_date = expiry_date.strftime("%d %B %Y года")
|
||||
|
||||
response_message = (
|
||||
f"Ключ: <pre>{key}</pre>\n"
|
||||
f"Дата истечения: <b>{formatted_expiry_date}</b>\n"
|
||||
f"{days_left_message}\n"
|
||||
f"Сервер: <b>{server_name}</b>"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Изменить время истечения",
|
||||
callback_data=f"change_expiry|{email}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="❌ Удалить ключ",
|
||||
callback_data=f"delete_key_admin|{email}",
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data="back_to_user_editor"
|
||||
)
|
||||
)
|
||||
await callback_query.message.edit_text(
|
||||
response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text(
|
||||
"<b>Информация о ключе не найдена.</b>", parse_mode="HTML"
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении информации о ключе: {e}")
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "search_by_key_name", IsAdminFilter())
|
||||
async def prompt_key_name(callback_query: CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.edit_text("🔑 Введите имя ключа:")
|
||||
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):
|
||||
key_name = sanitize_key_name(message.text)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
user_records = await conn.fetch(
|
||||
"""
|
||||
SELECT c.tg_id, c.balance, k.email, k.key, k.expiry_time, k.server_id
|
||||
FROM connections c
|
||||
JOIN keys k ON c.tg_id = k.tg_id
|
||||
WHERE k.email = $1
|
||||
""",
|
||||
key_name,
|
||||
)
|
||||
|
||||
if not user_records:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в меню администратора",
|
||||
callback_data="back_to_user_editor",
|
||||
)
|
||||
)
|
||||
|
||||
await message.reply(
|
||||
"🚫 Пользователь с указанным именем ключа не найден.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
response_messages = []
|
||||
key_buttons = InlineKeyboardBuilder()
|
||||
|
||||
for record in user_records:
|
||||
balance = record["balance"]
|
||||
email = record["email"]
|
||||
key = record["key"]
|
||||
expiry_time = record["expiry_time"]
|
||||
server_id = record["server_id"]
|
||||
server_name = "Неизвестный сервер"
|
||||
|
||||
for cluster in CLUSTERS.values():
|
||||
if server_id in cluster:
|
||||
server_name = cluster[server_id].get("name", "Неизвестный сервер")
|
||||
break
|
||||
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000).strftime(
|
||||
"%d %B %Y"
|
||||
)
|
||||
|
||||
response_messages.append(
|
||||
f"🔑 Ключ: <pre>{key}</pre>\n"
|
||||
f"⏰ Дата истечения: <b>{expiry_date}</b>\n"
|
||||
f"💰 Баланс пользователя: <b>{balance}</b>\n"
|
||||
f"🌐 Сервер: <b>{server_name}</b>"
|
||||
)
|
||||
|
||||
key_buttons.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Изменить время истечения",
|
||||
callback_data=f"change_expiry|{email}",
|
||||
)
|
||||
)
|
||||
key_buttons.row(
|
||||
InlineKeyboardButton(
|
||||
text="❌ Удалить ключ", callback_data=f"delete_key_admin|{email}"
|
||||
)
|
||||
)
|
||||
|
||||
key_buttons.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_user_editor")
|
||||
)
|
||||
|
||||
await message.reply(
|
||||
"\n".join(response_messages),
|
||||
reply_markup=key_buttons.as_markup(),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
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.edit_text(
|
||||
f"⏳ Введите новое время истечения для ключа <b>{email}</b> в формате <code>YYYY-MM-DD HH:MM:SS</code>:",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
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):
|
||||
user_data = await state.get_data()
|
||||
email = user_data.get("email")
|
||||
|
||||
if not email:
|
||||
await message.reply("Email не найден в состоянии.")
|
||||
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:
|
||||
await message.reply(f"Клиент с email {email} не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow(
|
||||
"SELECT server_id FROM keys WHERE client_id = $1", client_id
|
||||
)
|
||||
if not record:
|
||||
await message.reply("Клиент не найден в базе данных.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
async def update_key_on_all_servers():
|
||||
tasks = []
|
||||
for cluster_id in CLUSTERS:
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
renew_key_in_cluster(
|
||||
cluster_id,
|
||||
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="back_to_user_editor"
|
||||
)
|
||||
)
|
||||
await message.reply(
|
||||
response_message, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except ValueError:
|
||||
await message.reply("❌ Пожалуйста, используйте формат: YYYY-MM-DD HH:MM:SS.")
|
||||
except Exception as e:
|
||||
await message.reply(f"Произошла ошибка: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_key_admin|"), IsAdminFilter())
|
||||
async def process_callback_delete_key(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
client_id = await conn.fetchval(
|
||||
"SELECT client_id FROM keys WHERE email = $1", email
|
||||
)
|
||||
|
||||
if client_id is None:
|
||||
await bot.edit_message_text(
|
||||
"Ключ не найден.",
|
||||
chat_id=tg_id,
|
||||
message_id=callback_query.message.message_id,
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить", callback_data=f"confirm_delete_admin|{client_id}"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Нет, отменить", callback_data="view_keys"
|
||||
)
|
||||
)
|
||||
await bot.edit_message_text(
|
||||
"<b>❓ Вы уверены, что хотите удалить ключ?</b>",
|
||||
chat_id=tg_id,
|
||||
message_id=callback_query.message.message_id,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete_admin|"), IsAdminFilter())
|
||||
async def process_callback_confirm_delete(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
client_id = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow(
|
||||
"SELECT email FROM keys WHERE client_id = $1", client_id
|
||||
)
|
||||
|
||||
if record:
|
||||
email = record["email"]
|
||||
response_message = "✅ Ключ успешно удален."
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys")
|
||||
)
|
||||
|
||||
async def delete_key_from_servers(email, client_id):
|
||||
tasks = []
|
||||
for cluster_id in CLUSTERS:
|
||||
tasks.append(
|
||||
delete_key_from_cluster(cluster_id, email, client_id)
|
||||
)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
await delete_key_from_servers(email, client_id)
|
||||
await delete_key_from_db(client_id)
|
||||
|
||||
await bot.edit_message_text(
|
||||
response_message,
|
||||
chat_id=tg_id,
|
||||
message_id=callback_query.message.message_id,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
response_message = "🚫 Ключ не найден или уже удален."
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys")
|
||||
)
|
||||
await bot.edit_message_text(
|
||||
response_message,
|
||||
chat_id=tg_id,
|
||||
message_id=callback_query.message.message_id,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await bot.edit_message_text(
|
||||
f"Ошибка при удалении ключа: {e}",
|
||||
chat_id=tg_id,
|
||||
message_id=callback_query.message.message_id,
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
async def delete_key_from_db(client_id):
|
||||
"""Удаление ключа из базы данных"""
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
await conn.execute("DELETE FROM keys WHERE client_id = $1", client_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {client_id} из базы данных: {e}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "back_to_user_editor")
|
||||
async def back_to_user_editor(callback_query: CallbackQuery):
|
||||
await back_to_admin_menu(callback_query)
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .ads_handler import router
|
||||
@@ -0,0 +1,155 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from config import USERNAME_BOT
|
||||
from database import (
|
||||
create_tracking_source,
|
||||
get_all_tracking_sources,
|
||||
get_tracking_source_stats,
|
||||
)
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback
|
||||
from .keyboard import (
|
||||
AdminAdsCallback,
|
||||
build_ads_delete_confirm_kb,
|
||||
build_ads_kb,
|
||||
build_ads_list_kb,
|
||||
build_ads_stats_kb,
|
||||
build_cancel_input_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminAdsState(StatesGroup):
|
||||
waiting_for_new_name = State()
|
||||
waiting_for_new_code = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
|
||||
async def handle_ads_menu(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
|
||||
|
||||
|
||||
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
|
||||
async def handle_ads_create(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(AdminAdsState.waiting_for_new_name)
|
||||
await callback_query.message.edit_text(
|
||||
"📝 Введите <b>название</b> новой ссылки:", reply_markup=build_cancel_input_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminAdsState.waiting_for_new_name, IsAdminFilter())
|
||||
async def handle_ads_name_input(message: Message, state: FSMContext):
|
||||
name = message.text.strip()
|
||||
await state.update_data(name=name)
|
||||
await state.set_state(AdminAdsState.waiting_for_new_code)
|
||||
await message.answer(
|
||||
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.", reply_markup=build_cancel_input_kb()
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
|
||||
async def handle_ads_code_input(message: Message, state: FSMContext, session):
|
||||
code = message.text.strip()
|
||||
data = await state.get_data()
|
||||
name = data["name"]
|
||||
code_with_prefix = f"utm_{code}"
|
||||
|
||||
try:
|
||||
await create_tracking_source(
|
||||
name=name, code=code_with_prefix, type_="utm", created_by=message.from_user.id, session=session
|
||||
)
|
||||
stats = await get_tracking_source_stats(code_with_prefix, session)
|
||||
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}")
|
||||
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):
|
||||
try:
|
||||
ads = await get_all_tracking_sources(session)
|
||||
reply_markup = build_ads_list_kb(ads, current_page=1, total_pages=1)
|
||||
await callback_query.message.edit_text(
|
||||
"📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении списка UTM: {e}")
|
||||
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):
|
||||
code = callback_data.code
|
||||
try:
|
||||
stats = await get_tracking_source_stats(code, session)
|
||||
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), parse_mode=ParseMode.HTML
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при просмотре статистики: {e}")
|
||||
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):
|
||||
code = callback_data.code
|
||||
try:
|
||||
await session.execute("UPDATE users SET source_code = NULL WHERE source_code = $1", code)
|
||||
await session.execute("DELETE FROM tracking_sources WHERE code = $1", code)
|
||||
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("❌ Не удалось удалить ссылку.")
|
||||
|
||||
|
||||
def format_ads_stats(stats: dict, username_bot: str) -> str:
|
||||
return (
|
||||
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
|
||||
f"📌 <b>Название:</b> {stats['name']}\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start={stats['code']}</code>\n"
|
||||
f"🕓 <b>Создана:</b> {stats['created_at'].strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"💡 <b>Активность:</b>\n"
|
||||
f"└ 🆕 <b>Регистраций:</b> <b>{stats.get('registrations', 0)}</b>\n"
|
||||
f"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n"
|
||||
f"\n💰 <b>Финансовая информация:</b>\n"
|
||||
f"└ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n\n"
|
||||
f"<i>Просмотр статистики и управление рекламными ссылками</i>."
|
||||
)
|
||||
|
||||
|
||||
@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())
|
||||
@@ -0,0 +1,77 @@
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import build_admin_back_btn
|
||||
|
||||
|
||||
class AdminAdsCallback(CallbackData, prefix="admin_ads"):
|
||||
action: str
|
||||
code: str | None = None
|
||||
|
||||
|
||||
def build_ads_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="➕ Новая ссылка", callback_data=AdminAdsCallback(action="create").pack())
|
||||
builder.button(text="📊 Список", callback_data=AdminAdsCallback(action="list").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for ad in ads:
|
||||
builder.button(
|
||||
text=f"📎 {ad['name']}",
|
||||
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
|
||||
)
|
||||
|
||||
pagination_buttons = []
|
||||
if current_page > 1:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
|
||||
)
|
||||
)
|
||||
if current_page < total_pages:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=AdminAdsCallback(action="list", code=f"{current_page + 1}").pack(),
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
|
||||
builder.row(build_admin_back_btn("ads"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🗑️ Удалить", callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack())
|
||||
builder.row(build_admin_back_btn("ads"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_ads_delete_confirm_kb(code: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=AdminAdsCallback(
|
||||
action="delete",
|
||||
code=code,
|
||||
).pack(),
|
||||
)
|
||||
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="view", code=code).pack())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_cancel_input_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="cancel_input", code="none").pack())
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .backups_handler import router
|
||||
@@ -0,0 +1,31 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from backup import backup_database
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "backups"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_backups(callback_query: CallbackQuery):
|
||||
kb = build_admin_back_kb("management")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="💾 Инициализация резервного копирования базы данных...", reply_markup=kb
|
||||
)
|
||||
|
||||
exception = await backup_database()
|
||||
|
||||
if exception:
|
||||
text = f"❌ Ошибка при создании резервной копии: {exception}"
|
||||
else:
|
||||
text = "✅ Резервная копия успешно создана и отправлена администраторам."
|
||||
|
||||
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,94 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery
|
||||
|
||||
from database import delete_user_data
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import build_bans_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "bans"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_bans(callback_query: CallbackQuery):
|
||||
text = "🚫 Заблокировавшие бота\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!"
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_bans_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "bans_export"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_bans_export(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("management")
|
||||
|
||||
try:
|
||||
banned_users = await session.fetch("SELECT tg_id, blocked_at FROM blocked_users")
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
csv_output = io.StringIO()
|
||||
writer = csv.writer(csv_output)
|
||||
writer.writerow(["tg_id", "blocked_at"])
|
||||
for user in banned_users:
|
||||
writer.writerow([user["tg_id"], user["blocked_at"]])
|
||||
|
||||
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: Any):
|
||||
kb = build_admin_back_kb("bans")
|
||||
|
||||
try:
|
||||
blocked_users = await session.fetch("SELECT tg_id FROM blocked_users")
|
||||
blocked_ids = [record["tg_id"] for record in blocked_users]
|
||||
|
||||
if not blocked_ids:
|
||||
await callback_query.message.answer(
|
||||
text="📂 Нет заблокировавших пользователей для удаления.",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
for tg_id in blocked_ids:
|
||||
await delete_user_data(session, tg_id)
|
||||
|
||||
await session.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids)
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
def build_bans_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="📄 Выгрузить в CSV", callback_data=AdminPanelCallback(action="bans_export").pack())
|
||||
builder.button(text="🗑️ Удалить из БД", callback_data=AdminPanelCallback(action="bans_delete_banned").pack())
|
||||
builder.row(build_admin_back_btn("management"))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .clusters_handler import router
|
||||
@@ -0,0 +1,857 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from backup import create_backup_and_send_to_admins
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
DATABASE_URL,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
TOTAL_GB,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from database import check_unique_server_name, get_servers, update_key_expiry
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.key_utils import (
|
||||
create_client_on_server,
|
||||
create_key_on_cluster,
|
||||
delete_key_from_cluster,
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminClusterCallback,
|
||||
AdminServerCallback,
|
||||
build_cluster_management_kb,
|
||||
build_clusters_editor_kb,
|
||||
build_manage_cluster_kb,
|
||||
build_panel_type_kb,
|
||||
build_sync_cluster_kb,
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "clusters"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_servers(callback_query: CallbackQuery):
|
||||
servers = await get_servers()
|
||||
|
||||
text = (
|
||||
"<b>🔧 Управление кластерами</b>\n\n"
|
||||
"<blockquote>"
|
||||
"🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.\n"
|
||||
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер."
|
||||
"</blockquote>\n\n"
|
||||
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_clusters_editor_kb(servers),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "add"), IsAdminFilter())
|
||||
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🔧 <b>Введите имя нового кластера:</b>\n\n"
|
||||
"<b>Имя должно быть уникальным!</b>\n"
|
||||
"<b>Имя не должно превышать 12 символов!</b>\n\n"
|
||||
"<i>Пример:</i> <code>cluster1</code> или <code>us_east_1</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("clusters"))
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_cluster_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_cluster_name, IsAdminFilter())
|
||||
async def handle_cluster_name_input(message: Message, state: FSMContext):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не может быть пустым! Попробуйте снова.", reply_markup=build_admin_back_kb("clusters")
|
||||
)
|
||||
return
|
||||
|
||||
if len(message.text) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
cluster_name = message.text.strip()
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_server_name, IsAdminFilter())
|
||||
async def handle_server_name_input(message: Message, state: FSMContext, session: Any):
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("clusters")
|
||||
)
|
||||
return
|
||||
|
||||
server_name = message.text.strip()
|
||||
|
||||
if len(server_name) > 12:
|
||||
await message.answer(
|
||||
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
if not await check_unique_server_name(server_name, session, cluster_name):
|
||||
await message.answer(
|
||||
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(server_name=server_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"🔍 Ссылку можно найти в адресной строке браузера при входе в панель управления сервером.\n\n"
|
||||
"ℹ️ <b>Формат для 3X-UI:</b>\n"
|
||||
"<code>https://your-domain.com:port/panel_path/</code>\n\n"
|
||||
"ℹ️ <b>Формат для Remnawave:</b>\n"
|
||||
"<code>https://your-domain.com/api</code>"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_api_url)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter())
|
||||
async def handle_api_url_input(message: Message, state: FSMContext):
|
||||
api_url = message.text.strip().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)
|
||||
|
||||
text = (
|
||||
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"Если вы используете Remnawave — введите <code>0</code>\n\n"
|
||||
"<i>Формат:</i> <code>https://your_domain:port/sub_path</code>"
|
||||
)
|
||||
|
||||
await message.answer(text=text, reply_markup=build_admin_back_kb("clusters"))
|
||||
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
|
||||
async def handle_subscription_url_input(message: Message, state: FSMContext):
|
||||
raw = message.text.strip()
|
||||
subscription_url = None if raw == "0" else raw.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)
|
||||
|
||||
await message.answer(
|
||||
text=f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
f"Для Remnawave это UUID Инбаунда, для 3x-ui — просто ID (например, <code>1</code>).",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
|
||||
|
||||
|
||||
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
|
||||
async def handle_inbound_id_input(message: Message, state: FSMContext):
|
||||
inbound_id = message.text.strip()
|
||||
await state.update_data(inbound_id=inbound_id)
|
||||
|
||||
await message.answer(
|
||||
text=(
|
||||
"🧩 <b>Выберите тип панели для этого сервера:</b>\n\n"
|
||||
"⚠️ <b>Внимание:</b> Некоторые функции <b>Remnawave</b> находятся в разработке.\n"
|
||||
"Поддержка режима выбора стран — <b>ограничена</b>."
|
||||
),
|
||||
reply_markup=build_panel_type_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])), IsAdminFilter())
|
||||
async def handle_panel_type_selection(
|
||||
callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext
|
||||
):
|
||||
panel_type = "3x-ui" if callback_data.action == "panel_3xui" else "remnawave"
|
||||
|
||||
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")
|
||||
inbound_id = user_data.get("inbound_id")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id, panel_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
cluster_name,
|
||||
server_name,
|
||||
api_url,
|
||||
subscription_url,
|
||||
inbound_id,
|
||||
panel_type,
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Сервер <b>{server_name}</b> с панелью <b>{panel_type}</b> успешно добавлен в кластер <b>{cluster_name}</b>!",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
async def handle_clusters_manage(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
|
||||
):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔧 Управление кластером {cluster_name}</b>",
|
||||
reply_markup=build_cluster_management_kb(cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
|
||||
async def handle_cluster_servers(callback: CallbackQuery):
|
||||
cluster_name = callback.data.split("|", 1)[1]
|
||||
servers = await get_servers()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=f"<b>📡 Серверы в кластере {cluster_name}</b>",
|
||||
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
|
||||
)
|
||||
|
||||
|
||||
@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":
|
||||
remna = RemnawaveAPI(server["api_url"])
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise Exception("Не удалось авторизоваться")
|
||||
|
||||
server_inbound_id = server.get("inbound_id")
|
||||
if not server_inbound_id:
|
||||
raise Exception("Не указан inbound_id сервера")
|
||||
|
||||
all_nodes = await remna.get_all_nodes()
|
||||
if not all_nodes:
|
||||
raise Exception("Не удалось получить список нод")
|
||||
|
||||
matching_node = None
|
||||
for node in all_nodes:
|
||||
excluded_inbounds = node.get("excludedInbounds", [])
|
||||
if server_inbound_id not in excluded_inbounds:
|
||||
matching_node = node
|
||||
break
|
||||
|
||||
if not matching_node:
|
||||
raise Exception("Нода, обслуживающая этот inbound_id, не найдена")
|
||||
|
||||
online_remna_users = matching_node.get("usersOnline", 0)
|
||||
total_online_users += online_remna_users
|
||||
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_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_admin_back_kb("clusters"))
|
||||
|
||||
|
||||
@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:
|
||||
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>Бэкапы отправлены в боты панелей.</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: Any):
|
||||
server_name = callback_data.data
|
||||
|
||||
try:
|
||||
query_keys = """
|
||||
SELECT s.*, k.tg_id, k.client_id, k.email, k.expiry_time
|
||||
FROM servers s
|
||||
JOIN keys k ON s.cluster_name = k.server_id
|
||||
WHERE s.server_name = $1;
|
||||
"""
|
||||
keys_to_sync = await session.fetch(query_keys, server_name)
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Нет ключей для синхронизации в сервере {server_name}.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
text = f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
)
|
||||
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
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,
|
||||
)
|
||||
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: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
try:
|
||||
query_keys = """
|
||||
SELECT tg_id, client_id, email, expiry_time, remnawave_link
|
||||
FROM keys
|
||||
WHERE server_id = $1
|
||||
"""
|
||||
keys_to_sync = await session.fetch(query_keys, cluster_name)
|
||||
|
||||
if not keys_to_sync:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
|
||||
)
|
||||
for key in keys_to_sync:
|
||||
try:
|
||||
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"])
|
||||
|
||||
await session.execute(
|
||||
"DELETE FROM keys WHERE tg_id = $1 AND client_id = $2", key["tg_id"], key["client_id"]
|
||||
)
|
||||
|
||||
await create_key_on_cluster(
|
||||
cluster_name,
|
||||
key["tg_id"],
|
||||
key["client_id"],
|
||||
key["email"],
|
||||
key["expiry_time"],
|
||||
session=session,
|
||||
remnawave_link=key.get("remnawave_link"),
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в {cluster_name}: {e}")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при синхронизации: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "add"), IsAdminFilter())
|
||||
async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
|
||||
cluster_name = callback_data.data
|
||||
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
text = (
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
await state.set_state(AdminClusterStates.waiting_for_server_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: Any):
|
||||
try:
|
||||
days = int(message.text.strip())
|
||||
if days <= 0:
|
||||
raise ValueError
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
now = int(time.time() * 1000)
|
||||
add_ms = days * 86400 * 1000
|
||||
total_gb = int((days / 30) * TOTAL_GB * 1024**3)
|
||||
|
||||
keys = await session.fetch(
|
||||
"SELECT tg_id, client_id, email, expiry_time FROM keys WHERE server_id = $1",
|
||||
cluster_name,
|
||||
)
|
||||
|
||||
if not keys:
|
||||
await message.answer("❌ Нет подписок в этом кластере.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
for key in keys:
|
||||
new_expiry = (key["expiry_time"] or now) + add_ms
|
||||
await renew_key_in_cluster(
|
||||
cluster_name,
|
||||
email=key["email"],
|
||||
client_id=key["client_id"],
|
||||
new_expiry_time=new_expiry,
|
||||
total_gb=total_gb,
|
||||
)
|
||||
await update_key_expiry(key["client_id"], new_expiry, session)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
|
||||
)
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число дней.")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении дней: {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: Any):
|
||||
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")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
existing_cluster = await conn.fetchval(
|
||||
"SELECT cluster_name FROM servers WHERE cluster_name = $1 LIMIT 1", new_cluster_name
|
||||
)
|
||||
if existing_cluster:
|
||||
await message.answer(
|
||||
text=f"❌ Кластер с именем '{new_cluster_name}' уже существует. Введите другое имя.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
keys_count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", old_cluster_name)
|
||||
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"UPDATE servers SET cluster_name = $1 WHERE cluster_name = $2", new_cluster_name, old_cluster_name
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await conn.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_cluster_name
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
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 conn.close()
|
||||
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):
|
||||
old_server_name = callback_data.data
|
||||
|
||||
servers = await get_servers()
|
||||
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: Any):
|
||||
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")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
existing_server = await conn.fetchval(
|
||||
"SELECT server_name FROM servers WHERE cluster_name = $1 AND server_name = $2 LIMIT 1",
|
||||
cluster_name,
|
||||
new_server_name,
|
||||
)
|
||||
if existing_server:
|
||||
await message.answer(
|
||||
text=f"❌ Сервер с именем '{new_server_name}' уже существует в кластере '{cluster_name}'. Введите другое имя.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
keys_count = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", old_server_name)
|
||||
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"UPDATE servers SET server_name = $1 WHERE cluster_name = $2 AND server_name = $3",
|
||||
new_server_name,
|
||||
cluster_name,
|
||||
old_server_name,
|
||||
)
|
||||
|
||||
if keys_count > 0:
|
||||
await conn.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_server_name, old_server_name
|
||||
)
|
||||
|
||||
final_text = f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' в кластере '{cluster_name}'!"
|
||||
|
||||
await message.answer(
|
||||
text=final_text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
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 conn.close()
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("transfer_to_server|"))
|
||||
async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContext):
|
||||
data = callback_query.data.split("|")
|
||||
new_server_name = data[1]
|
||||
old_server_name = data[2]
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await conn.execute("UPDATE keys SET server_id = $1 WHERE server_id = $2", new_server_name, old_server_name)
|
||||
|
||||
await conn.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, old_server_name
|
||||
)
|
||||
|
||||
base_text = f"✅ Ключи успешно перенесены на сервер '{new_server_name}', сервер '{old_server_name}' удален!"
|
||||
sync_reminder = '\n\n⚠️ Не забудьте сделать "Синхронизацию".'
|
||||
final_text = base_text + (sync_reminder if USE_COUNTRY_SELECTION else "")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=final_text,
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при переносе ключей на сервер {new_server_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при переносе ключей: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("transfer_to_cluster|"))
|
||||
async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMContext):
|
||||
data = callback_query.data.split("|")
|
||||
new_cluster_name = data[1]
|
||||
old_cluster_name = data[2]
|
||||
old_server_name = data[3]
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await conn.execute("UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_server_name)
|
||||
await conn.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE server_id = $2", new_cluster_name, old_cluster_name
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, old_server_name
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Ключи успешно перенесены в кластер '{new_cluster_name}', сервер '{old_server_name}' и кластер '{old_cluster_name}' удалены!\n\n⚠️ Не забудьте сделать \"Синхронизацию\".",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при переносе ключей в кластер {new_cluster_name}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при переносе ключей: {e}",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
await state.clear()
|
||||
@@ -0,0 +1,132 @@
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
from ..servers.keyboard import AdminServerCallback
|
||||
|
||||
|
||||
class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
|
||||
action: str
|
||||
data: str | None = None
|
||||
|
||||
|
||||
def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
cluster_names = list(servers.keys())
|
||||
for i in range(0, len(cluster_names), 2):
|
||||
builder.row(*[
|
||||
InlineKeyboardButton(
|
||||
text=f"⚙️ {name}",
|
||||
callback_data=AdminClusterCallback(action="manage", data=name).pack(),
|
||||
)
|
||||
for name in cluster_names[i : i + 2]
|
||||
])
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="➕ Добавить кластер", callback_data=AdminClusterCallback(action="add").pack())
|
||||
)
|
||||
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for server in cluster_servers:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"🌍 {server['server_name']}",
|
||||
callback_data=AdminServerCallback(action="manage", data=server["server_name"]).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Добавить сервер",
|
||||
callback_data=AdminServerCallback(action="add", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(build_admin_back_btn("clusters"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📡 Серверы",
|
||||
callback_data=f"cluster_servers|{cluster_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Доступность",
|
||||
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Синхронизация",
|
||||
callback_data=AdminClusterCallback(action="sync", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💾 Создать бэкап",
|
||||
callback_data=AdminClusterCallback(action="backup", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Добавить время",
|
||||
callback_data=AdminClusterCallback(action="add_time", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Сменить название",
|
||||
callback_data=AdminClusterCallback(action="rename", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for server in cluster_servers:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"🔄 Синхронизировать {server['server_name']}",
|
||||
callback_data=AdminClusterCallback(action="sync-server", data=server["server_name"]).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📍 Синхронизировать кластер",
|
||||
callback_data=AdminClusterCallback(action="sync-cluster", data=cluster_name).pack(),
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(build_admin_back_btn("clusters"))
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_panel_type_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack())
|
||||
builder.button(text="🌀 Remnawave", callback_data=AdminClusterCallback(action="panel_remnawave").pack())
|
||||
builder.row(build_admin_back_btn("clusters"))
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .coupons_handler import router
|
||||
@@ -0,0 +1,317 @@
|
||||
import html
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineQuery,
|
||||
InlineQueryResultArticle,
|
||||
InputTextMessageContent,
|
||||
Message,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import INLINE_MODE, USERNAME_BOT
|
||||
from database import (
|
||||
create_coupon,
|
||||
delete_coupon,
|
||||
get_all_coupons,
|
||||
)
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.buttons import BACK
|
||||
from handlers.utils import format_days
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb, format_coupons_list
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminCouponsState(StatesGroup):
|
||||
waiting_for_coupon_type = State()
|
||||
waiting_for_balance_data = State()
|
||||
waiting_for_days_data = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "coupons"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_coupons(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "coupons_create"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = "🎫 <b>Выберите тип купона:</b>"
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
|
||||
kb.button(text="⏳ Время", callback_data="coupon_type_days")
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_coupon_type)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_balance")
|
||||
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
|
||||
)
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_balance_data)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_days")
|
||||
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b> 👈\n\n"
|
||||
)
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_days_data)
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
|
||||
async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
|
||||
text = message.text.strip()
|
||||
parts = text.split()
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈"
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
coupon_code = parts[0]
|
||||
coupon_amount = int(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
if coupon_amount <= 0:
|
||||
raise ValueError("Сумма должна быть больше 0")
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(coupon_code, coupon_amount, usage_limit, session, days=None)
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||
)
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
if INLINE_MODE:
|
||||
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_days_data, IsAdminFilter())
|
||||
async def handle_days_coupon_input(message: Message, state: FSMContext, session: Any):
|
||||
text = message.text.strip()
|
||||
parts = text.split()
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"🏷️ <b>код</b> ⏳ <i>дни</i> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b> 👈"
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
coupon_code = parts[0]
|
||||
days = int(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
if days <= 0:
|
||||
raise ValueError("Количество дней должно быть больше 0")
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Дни должны быть числом, а лимит — целым числом."
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(coupon_code, 0, usage_limit, session, days=days)
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"⏳ <b>{format_days(days)}</b>\n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||
)
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
if INLINE_MODE:
|
||||
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "coupons_list"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
data = AdminPanelCallback.unpack(callback_query.data)
|
||||
page = data.page if data.page is not None else 1
|
||||
await update_coupons_list(callback_query.message, session, page)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении списка купонов: {e}")
|
||||
await callback_query.message.edit_text("Произошла ошибка при получении списка купонов.")
|
||||
|
||||
|
||||
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter())
|
||||
async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
|
||||
coupon_code = callback_data.coupon_code
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(
|
||||
text="✅ Да, удалить", callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack()
|
||||
)
|
||||
kb.button(
|
||||
text="❌ Нет, отменить", callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack()
|
||||
)
|
||||
kb.adjust(1)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?", reply_markup=kb.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter())
|
||||
async def confirm_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
|
||||
coupon_code = callback_data.coupon_code
|
||||
confirm = callback_data.confirm
|
||||
|
||||
if confirm:
|
||||
try:
|
||||
result = await delete_coupon(coupon_code, session)
|
||||
if not result:
|
||||
await callback_query.message.edit_text(
|
||||
f"❌ Купон с кодом {coupon_code} не найден.", reply_markup=build_admin_back_kb("coupons")
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении купона: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
"Произошла ошибка при удалении купона.", reply_markup=build_admin_back_kb("coupons")
|
||||
)
|
||||
return
|
||||
|
||||
await update_coupons_list(callback_query.message, session)
|
||||
|
||||
|
||||
async def update_coupons_list(message, session: Any, page: int = 1):
|
||||
per_page = 10
|
||||
result = await get_all_coupons(session, page, per_page)
|
||||
coupons = result["coupons"]
|
||||
|
||||
if not coupons:
|
||||
await message.edit_text(
|
||||
text="❌ На данный момент нет доступных купонов!",
|
||||
reply_markup=build_admin_back_kb("coupons"),
|
||||
)
|
||||
return
|
||||
|
||||
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
|
||||
text = format_coupons_list(coupons, USERNAME_BOT)
|
||||
await message.edit_text(text=text, reply_markup=kb)
|
||||
|
||||
|
||||
@router.inline_query(F.query.startswith("coupon_"))
|
||||
async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
|
||||
if not INLINE_MODE:
|
||||
return
|
||||
|
||||
coupon_code = inline_query.query.split("coupon_")[1]
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
|
||||
coupons = await get_all_coupons(session, page=1, per_page=10)
|
||||
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
|
||||
|
||||
if not coupon:
|
||||
await inline_query.answer(
|
||||
results=[],
|
||||
switch_pm_text="Купон не найден",
|
||||
switch_pm_parameter="coupons",
|
||||
cache_time=1,
|
||||
)
|
||||
return
|
||||
|
||||
title = f"Купон {coupon['code']}"
|
||||
description = (
|
||||
f"Получи {coupon['amount']} рублей!"
|
||||
if coupon["amount"] > 0
|
||||
else f"Продли подписку на {format_days(coupon['days'])}!"
|
||||
)
|
||||
message_text = (
|
||||
f"🎫 <b>Купон:</b> {coupon['code']}\n"
|
||||
f"{'💰 <b>Бонус:</b> ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ <b>Продление:</b> ' + format_days(coupon['days'])}\n"
|
||||
f"👇 Нажми, чтобы активировать!"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="Активировать купон", url=coupon_link)
|
||||
|
||||
result = InlineQueryResultArticle(
|
||||
id=coupon_code,
|
||||
title=title,
|
||||
description=description,
|
||||
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await inline_query.answer(results=[result], cache_time=86400, is_personal=True)
|
||||
@@ -0,0 +1,74 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.buttons import BACK
|
||||
from handlers.utils import format_days
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
|
||||
coupon_code: str
|
||||
confirm: bool | None = None
|
||||
|
||||
|
||||
def build_coupons_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="➕ Создать купон", callback_data=AdminPanelCallback(action="coupons_create").pack())
|
||||
builder.button(text="Купоны", callback_data=AdminPanelCallback(action="coupons_list").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for coupon in coupons:
|
||||
coupon_code = coupon["code"]
|
||||
builder.button(
|
||||
text=f"❌{coupon_code}",
|
||||
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code).pack(),
|
||||
)
|
||||
|
||||
pagination_buttons = []
|
||||
if current_page > 1:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=BACK,
|
||||
callback_data=AdminPanelCallback(action="coupons_list", page=current_page - 1).pack(),
|
||||
)
|
||||
)
|
||||
if current_page < total_pages:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=AdminPanelCallback(action="coupons_list", page=current_page + 1).pack(),
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
|
||||
builder.row(build_admin_back_btn("coupons"))
|
||||
builder.adjust(2)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def format_coupons_list(coupons: list, username_bot: str) -> str:
|
||||
coupon_list = "📜 Список всех купонов:\n\n"
|
||||
for coupon in coupons:
|
||||
value_text = (
|
||||
f"💰 <b>Сумма:</b> {coupon['amount']} рублей"
|
||||
if coupon["amount"] > 0
|
||||
else f"⏳ <b>{format_days(coupon['days'])}</b>"
|
||||
)
|
||||
coupon_list += (
|
||||
f"🏷️ <b>Код:</b> {coupon['code']}\n"
|
||||
f"{value_text}\n"
|
||||
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
|
||||
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>\n\n"
|
||||
)
|
||||
return coupon_list
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .management_handler import router
|
||||
@@ -0,0 +1,21 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from middlewares import maintenance
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
def build_management_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="💾 Создать резервную копию", callback_data=AdminPanelCallback(action="backups").pack())
|
||||
builder.button(text="🚫 Заблокировавшие бота", callback_data=AdminPanelCallback(action="bans").pack())
|
||||
builder.button(text="🔄 Перезагрузить бота", callback_data=AdminPanelCallback(action="restart").pack())
|
||||
builder.button(text="🌐 Сменить домен", callback_data=AdminPanelCallback(action="change_domain").pack())
|
||||
builder.button(text="🔑 Восстановить пробники", callback_data=AdminPanelCallback(action="restore_trials").pack())
|
||||
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
|
||||
builder.button(text=maintenance_text, callback_data=AdminPanelCallback(action="toggle_maintenance").pack())
|
||||
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,87 @@
|
||||
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 asyncpg import Connection
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import AdminPanelCallback, build_management_kb
|
||||
from middlewares import maintenance
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminManagementStates(StatesGroup):
|
||||
waiting_for_new_domain = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
|
||||
async def handle_management(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(
|
||||
text="🤖 Управление ботом",
|
||||
reply_markup=build_management_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
|
||||
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
|
||||
"""Запрашивает у администратора новый домен."""
|
||||
await state.set_state(AdminManagementStates.waiting_for_new_domain)
|
||||
await callback_query.message.edit_text(
|
||||
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminManagementStates.waiting_for_new_domain)
|
||||
async def process_new_domain(message: Message, state: FSMContext, session: Connection):
|
||||
"""Обновляет домен в таблице keys."""
|
||||
new_domain = message.text.strip()
|
||||
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
|
||||
|
||||
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
|
||||
logger.warning("[DomainChange] Некорректный домен")
|
||||
await message.answer(
|
||||
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
|
||||
reply_markup=build_admin_back_kb("admin"),
|
||||
)
|
||||
return
|
||||
|
||||
new_domain_url = f"https://{new_domain}"
|
||||
logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
|
||||
|
||||
query = """
|
||||
UPDATE keys
|
||||
SET key = regexp_replace(key, '^https://[^/]+', $1::TEXT)
|
||||
WHERE key NOT LIKE $1 || '%'
|
||||
"""
|
||||
try:
|
||||
await session.execute(query, new_domain_url)
|
||||
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
|
||||
await message.answer(f"❌ Ошибка при обновлении домена: {e}", reply_markup=build_admin_back_kb("admin"))
|
||||
return
|
||||
|
||||
try:
|
||||
sample = await session.fetchrow("SELECT key FROM keys LIMIT 1")
|
||||
logger.info(f"[DomainChange] Пример обновленной записи: {sample}")
|
||||
except Exception as e:
|
||||
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
|
||||
|
||||
await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin"))
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
|
||||
async def toggle_maintenance_mode(callback: CallbackQuery):
|
||||
|
||||
maintenance.maintenance_mode = not maintenance.maintenance_mode
|
||||
|
||||
new_status = "включён" if maintenance.maintenance_mode else "выключен"
|
||||
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=build_management_kb())
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .panel_handler import router
|
||||
@@ -0,0 +1,64 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.buttons import BACK, MAIN_MENU
|
||||
|
||||
|
||||
class AdminPanelCallback(CallbackData, prefix="admin_panel"):
|
||||
action: str
|
||||
page: int
|
||||
|
||||
def __init__(self, /, **data: Any) -> None:
|
||||
if "page" not in data or data["page"] is None:
|
||||
data["page"] = 1
|
||||
super().__init__(**data)
|
||||
|
||||
|
||||
def build_panel_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="👤 Поиск пользователя", callback_data=AdminPanelCallback(action="search_user").pack())
|
||||
builder.button(text="🔑 Поиск по названию ключа", callback_data=AdminPanelCallback(action="search_key").pack())
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack()),
|
||||
InlineKeyboardButton(text="🤖 Управление", callback_data=AdminPanelCallback(action="management").pack()),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
|
||||
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
|
||||
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
|
||||
)
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1, 1, 2, 2, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_restart_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="✅ Да, перезагрузить", callback_data=AdminPanelCallback(action="restart_confirm").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup:
|
||||
return build_admin_singleton_kb(BACK, action)
|
||||
|
||||
|
||||
def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(build_admin_btn(text, action))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
|
||||
return build_admin_btn(BACK, action)
|
||||
|
||||
|
||||
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
|
||||
return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack())
|
||||
@@ -0,0 +1,50 @@
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from bot import version
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from .keyboard import AdminPanelCallback, build_panel_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
|
||||
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
|
||||
|
||||
await state.clear()
|
||||
|
||||
if callback_query.message.text:
|
||||
try:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_panel_kb())
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e):
|
||||
logger.warning("🔄 Попытка редактировать сообщение без изменений — пропущено.")
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
await callback_query.message.answer(text=text, reply_markup=build_panel_kb())
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin", IsAdminFilter())
|
||||
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
|
||||
await handle_admin_message(callback_query.message, state)
|
||||
|
||||
|
||||
@router.message(Command("admin"), IsAdminFilter())
|
||||
async def handle_admin_message(message: Message, state: FSMContext):
|
||||
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
|
||||
|
||||
await state.clear()
|
||||
await message.answer(text=text, reply_markup=build_panel_kb())
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .restart_handler import router
|
||||
@@ -0,0 +1,42 @@
|
||||
import subprocess
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "restart"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_restart(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(
|
||||
text="🤔 Вы уверены, что хотите перезагрузить бота?",
|
||||
reply_markup=build_restart_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "restart_confirm"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_restart_confirm(callback_query: CallbackQuery):
|
||||
kb = build_admin_back_kb()
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "systemctl", "restart", "bot.service"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
|
||||
except subprocess.CalledProcessError:
|
||||
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
|
||||
except Exception as e:
|
||||
await callback_query.message.edit_text(text=f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", reply_markup=kb)
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .sender_handler import router
|
||||
@@ -0,0 +1,45 @@
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import build_admin_back_btn
|
||||
|
||||
|
||||
class AdminSenderCallback(CallbackData, prefix="admin_sender"):
|
||||
type: str
|
||||
data: str | None = None
|
||||
|
||||
|
||||
def build_sender_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(InlineKeyboardButton(text="👥 Все пользователи", callback_data=AdminSenderCallback(type="all").pack()))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="✅ С подпиской", callback_data=AdminSenderCallback(type="subscribed").pack()),
|
||||
InlineKeyboardButton(text="❌ Без подписки", callback_data=AdminSenderCallback(type="unsubscribed").pack()),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📍 Не использовавшие триал", callback_data=AdminSenderCallback(type="untrial").pack()
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔥 Горячие лиды", callback_data=AdminSenderCallback(type="hotleads").pack()))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📢 Кластер", callback_data=AdminSenderCallback(type="cluster-select").pack())
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for cluster in clusters:
|
||||
name = cluster["cluster_name"]
|
||||
builder.button(text=f"🌐 {name}", callback_data=AdminSenderCallback(type="cluster", data=name).pack())
|
||||
|
||||
builder.adjust(2)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,160 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
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 filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class AdminSender(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "sender"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_sender(callback_query: CallbackQuery):
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Выберите группу пользователей для рассылки:",
|
||||
reply_markup=build_sender_kb(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminSenderCallback.filter(F.type != "cluster-select"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_sender_callback_text(
|
||||
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
|
||||
):
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Введите текст сообщения для рассылки:",
|
||||
reply_markup=build_admin_back_kb("sender"),
|
||||
)
|
||||
await state.update_data(type=callback_data.type, cluster_name=callback_data.data)
|
||||
await state.set_state(AdminSender.waiting_for_message)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminSenderCallback.filter(F.type == "cluster-select"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
|
||||
clusters = await session.fetch("SELECT DISTINCT cluster_name FROM servers")
|
||||
await callback_query.message.answer(
|
||||
"✍️ Выберите кластер для рассылки сообщений:",
|
||||
reply_markup=build_clusters_kb(clusters),
|
||||
)
|
||||
|
||||
|
||||
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
|
||||
async def handle_message_input(message: Message, state: FSMContext, session: Any):
|
||||
"""
|
||||
Обрабатывает ввод сообщения для рассылки (поддержка текста + фото).
|
||||
"""
|
||||
text_message = message.html_text if message.text else None
|
||||
photo = message.photo[-1].file_id if message.photo else None
|
||||
photo_url = message.caption if message.photo and message.caption and message.caption.startswith("http") else None
|
||||
|
||||
if not text_message and message.caption:
|
||||
text_message = message.caption
|
||||
|
||||
if not text_message and not photo and not photo_url:
|
||||
await message.answer("⚠ Ошибка! Отправьте текст или изображение для рассылки.")
|
||||
return
|
||||
|
||||
state_data = await state.get_data()
|
||||
send_to = state_data.get("type", "all")
|
||||
|
||||
now = int(datetime.utcnow().timestamp() * 1000)
|
||||
|
||||
if send_to == "subscribed":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT u.tg_id
|
||||
FROM users u
|
||||
JOIN keys k ON u.tg_id = k.tg_id
|
||||
WHERE k.expiry_time > $1
|
||||
""",
|
||||
now,
|
||||
)
|
||||
elif send_to == "unsubscribed":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT u.tg_id
|
||||
FROM users u
|
||||
LEFT JOIN keys k ON u.tg_id = k.tg_id
|
||||
GROUP BY u.tg_id
|
||||
HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
|
||||
""",
|
||||
now,
|
||||
)
|
||||
elif send_to == "untrial":
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users WHERE tg_id NOT IN (SELECT tg_id FROM keys)")
|
||||
elif send_to == "cluster":
|
||||
cluster_name = state_data.get("cluster_name")
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT u.tg_id
|
||||
FROM users u
|
||||
JOIN keys k ON u.tg_id = k.tg_id
|
||||
JOIN servers s ON k.server_id = s.cluster_name
|
||||
WHERE s.cluster_name = $1
|
||||
""",
|
||||
cluster_name,
|
||||
)
|
||||
elif send_to == "hotleads":
|
||||
tg_ids = await session.fetch(
|
||||
"""
|
||||
SELECT DISTINCT u.tg_id
|
||||
FROM users u
|
||||
JOIN payments p ON u.tg_id = p.tg_id
|
||||
LEFT JOIN keys k ON u.tg_id = k.tg_id
|
||||
WHERE p.status = 'success'
|
||||
AND k.tg_id IS NULL
|
||||
"""
|
||||
)
|
||||
else:
|
||||
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users")
|
||||
|
||||
total_users = len(tg_ids)
|
||||
success_count = 0
|
||||
|
||||
text = f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
|
||||
|
||||
await message.answer(text=text)
|
||||
|
||||
for record in tg_ids:
|
||||
tg_id = record["tg_id"]
|
||||
try:
|
||||
if photo or photo_url:
|
||||
await message.bot.send_photo(
|
||||
chat_id=tg_id, photo=photo if photo else photo_url, caption=text_message, parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.bot.send_message(chat_id=tg_id, text=text_message, parse_mode="HTML")
|
||||
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
|
||||
|
||||
text = (
|
||||
f"📤 <b>Рассылка завершена!</b>\n\n"
|
||||
f"👥 <b>Количество получателей:</b> {total_users}\n"
|
||||
f"✅ <b>Доставлено:</b> {success_count}\n"
|
||||
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
|
||||
)
|
||||
|
||||
await message.answer(text=text, reply_markup=build_admin_back_kb("sender"))
|
||||
await state.clear()
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .servers_handler import router
|
||||
@@ -0,0 +1,36 @@
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.buttons import BACK
|
||||
|
||||
|
||||
class AdminServerCallback(CallbackData, prefix="admin_server"):
|
||||
action: str
|
||||
data: str
|
||||
|
||||
|
||||
def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -> InlineKeyboardMarkup:
|
||||
from ..clusters.keyboard import AdminClusterCallback
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
toggle_text = "🔴 Отключить" if enabled else "🟢 Включить"
|
||||
toggle_action = "disable" if enabled else "enable"
|
||||
|
||||
builder.button(text=toggle_text, callback_data=AdminServerCallback(action=toggle_action, data=server_name).pack())
|
||||
|
||||
builder.button(
|
||||
text="📈 Задать лимит", callback_data=AdminServerCallback(action="set_limit", data=server_name).pack()
|
||||
)
|
||||
|
||||
builder.button(text="🗑️ Удалить", callback_data=AdminServerCallback(action="delete", data=server_name).pack())
|
||||
|
||||
builder.button(
|
||||
text="✏️ Сменить название", callback_data=AdminServerCallback(action="rename", data=server_name).pack()
|
||||
)
|
||||
|
||||
builder.button(text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack())
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,269 @@
|
||||
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 database import get_servers
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminServerCallback,
|
||||
build_manage_server_kb,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class ServerLimitState(StatesGroup):
|
||||
waiting_for_limit = State()
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "manage"), IsAdminFilter())
|
||||
async def handle_server_manage(callback_query: CallbackQuery, callback_data: AdminServerCallback):
|
||||
server_name = callback_data.data
|
||||
servers = await get_servers(include_enabled=True)
|
||||
|
||||
cluster_name, server = next(
|
||||
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
|
||||
)
|
||||
|
||||
if server:
|
||||
api_url = server["api_url"]
|
||||
subscription_url = server["subscription_url"]
|
||||
inbound_id = server["inbound_id"]
|
||||
max_keys = server.get("max_keys")
|
||||
limit_display = f"{max_keys}" if max_keys else "не задан"
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||
f"<b>📡 API URL:</b> {api_url}\n"
|
||||
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
|
||||
f"<b>🔑 Inbound ID:</b> {inbound_id}\n"
|
||||
f"<b>📈 Лимит ключей:</b> {limit_display}"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True)),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text(text="❌ Сервер не найден.")
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "delete"), IsAdminFilter())
|
||||
async def process_callback_delete_server(
|
||||
callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext, session: Any
|
||||
):
|
||||
from ..clusters.clusters_handler import AdminClusterStates
|
||||
|
||||
server_name = callback_data.data
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_name = None
|
||||
for c_name, server_list in servers.items():
|
||||
for server in server_list:
|
||||
if server["server_name"] == server_name:
|
||||
cluster_name = c_name
|
||||
break
|
||||
if cluster_name:
|
||||
break
|
||||
|
||||
if not cluster_name:
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Не удалось найти кластер для сервера '{server_name}'.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
return
|
||||
|
||||
keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", server_name)
|
||||
|
||||
if keys_count > 0:
|
||||
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
||||
|
||||
all_servers = await session.fetch(
|
||||
"""
|
||||
SELECT server_name, (SELECT COUNT(*) FROM keys WHERE server_id = servers.server_name) as key_count
|
||||
FROM servers
|
||||
WHERE server_name != $1
|
||||
""",
|
||||
server_name,
|
||||
)
|
||||
|
||||
if all_servers:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for server in all_servers:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{server['server_name']} ({server['key_count']})",
|
||||
callback_data=f"transfer_to_server|{server['server_name']}|{server_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"⚠️ На сервере '{server_name}' есть {keys_count} ключей. Выберите сервер для переноса ключей:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_server_transfer)
|
||||
return
|
||||
|
||||
remaining_servers = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM servers WHERE cluster_name = $1 AND server_name != $2", cluster_name, server_name
|
||||
)
|
||||
|
||||
if remaining_servers == 0:
|
||||
other_clusters = await session.fetch(
|
||||
"SELECT DISTINCT cluster_name FROM servers WHERE cluster_name != $1", cluster_name
|
||||
)
|
||||
|
||||
if other_clusters:
|
||||
cluster_keys_count = await session.fetchval("SELECT COUNT(*) FROM keys WHERE server_id = $1", cluster_name)
|
||||
|
||||
if cluster_keys_count > 0:
|
||||
await state.update_data(server_name=server_name, cluster_name=cluster_name)
|
||||
|
||||
all_clusters = await session.fetch(
|
||||
"""
|
||||
SELECT cluster_name, (SELECT COUNT(*) FROM keys WHERE server_id = servers.cluster_name) as key_count
|
||||
FROM servers
|
||||
WHERE cluster_name != $1
|
||||
GROUP BY cluster_name
|
||||
""",
|
||||
cluster_name,
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for cluster in all_clusters:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"{cluster['cluster_name']} ({cluster['key_count']})",
|
||||
callback_data=f"transfer_to_cluster|{cluster['cluster_name']}|{cluster_name}|{server_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=BACK, callback_data=AdminServerCallback(action="manage", data=server_name).pack()
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"⚠️ Это последний сервер в кластере '{cluster_name}'. На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
|
||||
return
|
||||
|
||||
await session.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, server_name
|
||||
)
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Сервер '{server_name}' удален. Кластер '{cluster_name}' также удален, так как в нем не осталось серверов.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
else:
|
||||
await session.execute(
|
||||
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2", cluster_name, server_name
|
||||
)
|
||||
await callback_query.message.edit_text(
|
||||
text=f"✅ Сервер '{server_name}' удален.",
|
||||
reply_markup=build_admin_back_kb("clusters"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter())
|
||||
async def toggle_server_enabled(callback_query: CallbackQuery, callback_data: AdminServerCallback, session: Any):
|
||||
server_name = callback_data.data
|
||||
action = callback_data.action
|
||||
|
||||
new_status = action == "enable"
|
||||
|
||||
await session.execute("UPDATE servers SET enabled = $1 WHERE server_name = $2", new_status, server_name)
|
||||
|
||||
servers = await get_servers(include_enabled=True)
|
||||
|
||||
cluster_name, server = next(
|
||||
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
|
||||
)
|
||||
|
||||
if not server:
|
||||
await callback_query.message.edit_text("❌ Сервер не найден.")
|
||||
return
|
||||
|
||||
max_keys = server.get("max_keys")
|
||||
limit_display = f"{max_keys}" if max_keys else "не задан"
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||
f"<b>📡 API URL:</b> {server['api_url']}\n"
|
||||
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
|
||||
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
|
||||
f"<b>📈 Лимит ключей:</b> {limit_display}"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=text,
|
||||
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=new_status),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter())
|
||||
async def ask_server_limit(callback: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
|
||||
server_name = callback_data.data
|
||||
await state.set_state(ServerLimitState.waiting_for_limit)
|
||||
await state.update_data(server_name=server_name)
|
||||
await callback.message.edit_text(
|
||||
f"Введите лимит ключей для сервера <b>{server_name}</b> (целое число, 0 — без лимита):",
|
||||
)
|
||||
|
||||
|
||||
@router.message(ServerLimitState.waiting_for_limit, IsAdminFilter())
|
||||
async def save_server_limit(message: types.Message, state: FSMContext, session: Any):
|
||||
try:
|
||||
limit = int(message.text.strip())
|
||||
if limit < 0:
|
||||
raise ValueError
|
||||
|
||||
data = await state.get_data()
|
||||
server_name = data["server_name"]
|
||||
|
||||
new_value = limit if limit > 0 else None
|
||||
await session.execute("UPDATE servers SET max_keys = $1 WHERE server_name = $2", new_value, server_name)
|
||||
|
||||
servers = await get_servers(include_enabled=True)
|
||||
cluster_name, server = next(
|
||||
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
|
||||
)
|
||||
|
||||
if not server:
|
||||
await message.answer("❌ Сервер не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
max_keys = server.get("max_keys")
|
||||
limit_display = f"{max_keys}" if max_keys is not None else "не задан"
|
||||
|
||||
text = (
|
||||
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||
f"<b>📡 API URL:</b> {server['api_url']}\n"
|
||||
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
|
||||
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
|
||||
f"<b>📈 Лимит ключей:</b> {limit_display}"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text, reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True))
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .stats_handler import router
|
||||
@@ -0,0 +1,26 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
|
||||
|
||||
|
||||
def build_stats_kb() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack())
|
||||
builder.button(
|
||||
text="📥 Выгрузить пользователей в CSV",
|
||||
callback_data=AdminPanelCallback(action="stats_export_users_csv").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📥 Выгрузить оплаты в CSV", callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack()
|
||||
)
|
||||
builder.button(
|
||||
text="📥 Выгрузить подписки в CSV",
|
||||
callback_data=AdminPanelCallback(action="stats_export_keys_csv").pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📥 Выгрузить горящих лидов", callback_data=AdminPanelCallback(action="stats_export_hot_leads_csv").pack()
|
||||
)
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,201 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
from utils.csv_export import export_hot_leads_csv, export_keys_csv, export_payments_csv, export_users_csv
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
|
||||
from .keyboard import build_stats_kb
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_stats(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
total_users = await session.fetchval("SELECT COUNT(*) FROM users")
|
||||
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
|
||||
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
|
||||
|
||||
total_payments_today = int(
|
||||
await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE")
|
||||
)
|
||||
total_payments_week = int(
|
||||
await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
|
||||
)
|
||||
)
|
||||
total_payments_month = int(
|
||||
await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
|
||||
)
|
||||
)
|
||||
total_payments_last_month = int(
|
||||
await session.fetchval(
|
||||
"""
|
||||
SELECT COALESCE(SUM(amount), 0)
|
||||
FROM payments
|
||||
WHERE created_at >= date_trunc('month', CURRENT_DATE - interval '1 month')
|
||||
AND created_at < date_trunc('month', CURRENT_DATE)
|
||||
"""
|
||||
)
|
||||
)
|
||||
total_payments_all_time = int(await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments"))
|
||||
|
||||
all_keys = await session.fetch("SELECT created_at, expiry_time FROM keys")
|
||||
|
||||
def count_subscriptions_by_duration(keys):
|
||||
periods = {"trial": 0, "1": 0, "3": 0, "6": 0, "12": 0}
|
||||
for key in keys:
|
||||
try:
|
||||
duration_days = (key["expiry_time"] - key["created_at"]) / (1000 * 60 * 60 * 24)
|
||||
|
||||
if duration_days <= 29:
|
||||
periods["trial"] += 1
|
||||
elif duration_days <= 89:
|
||||
periods["1"] += 1
|
||||
elif duration_days <= 179:
|
||||
periods["3"] += 1
|
||||
elif duration_days <= 359:
|
||||
periods["6"] += 1
|
||||
else:
|
||||
periods["12"] += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing key duration: {e}")
|
||||
continue
|
||||
return periods
|
||||
|
||||
subs_all_time = count_subscriptions_by_duration(all_keys)
|
||||
|
||||
registrations_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE")
|
||||
registrations_week = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)"
|
||||
)
|
||||
registrations_month = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)"
|
||||
)
|
||||
|
||||
users_updated_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE")
|
||||
|
||||
active_keys = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
)
|
||||
expired_keys = total_keys - active_keys
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
|
||||
|
||||
hot_leads_count = await session.fetchval("""
|
||||
SELECT COUNT(DISTINCT u.tg_id)
|
||||
FROM users u
|
||||
JOIN payments p ON u.tg_id = p.tg_id
|
||||
LEFT JOIN keys k ON u.tg_id = k.tg_id
|
||||
WHERE p.status = 'success'
|
||||
AND k.tg_id IS NULL
|
||||
""")
|
||||
|
||||
stats_message = (
|
||||
"📊 <b>Статистика проекта</b>\n\n"
|
||||
"👤 <b>Пользователи:</b>\n"
|
||||
f"├ 🗓️ За день: <b>{registrations_today}</b>\n"
|
||||
f"├ 📆 За неделю: <b>{registrations_week}</b>\n"
|
||||
f"├ 🗓️ За месяц: <b>{registrations_month}</b>\n"
|
||||
f"└ 🌐 Всего: <b>{total_users}</b>\n\n"
|
||||
"💡 <b>Активность:</b>\n"
|
||||
f"└ 👥 Сегодня были активны: <b>{users_updated_today}</b>\n\n"
|
||||
"🤝 <b>Реферальная система:</b>\n"
|
||||
f"└ 👥 Всего привлечено: <b>{total_referrals}</b>\n\n"
|
||||
"🔐 <b>Подписки:</b>\n"
|
||||
f"├ 📦 Всего сгенерировано: <b>{total_keys}</b>\n"
|
||||
f"├ ✅ Активных: <b>{active_keys}</b>\n"
|
||||
f"├ ❌ Просроченных: <b>{expired_keys}</b>\n"
|
||||
f"└ 📋 По срокам:\n"
|
||||
f" • 🎁 Триал: <b>{subs_all_time['trial']}</b>\n"
|
||||
f" • 🗓️ 1 мес: <b>{subs_all_time['1']}</b>\n"
|
||||
f" • 🗓️ 3 мес: <b>{subs_all_time['3']}</b>\n"
|
||||
f" • 🗓️ 6 мес: <b>{subs_all_time['6']}</b>\n"
|
||||
f" • 🗓️ 12 мес: <b>{subs_all_time['12']}</b>\n\n"
|
||||
"💰 <b>Финансы:</b>\n"
|
||||
f"├ 📅 За день: <b>{total_payments_today} ₽</b>\n"
|
||||
f"├ 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
|
||||
f"├ 📆 За месяц: <b>{total_payments_month} ₽</b>\n"
|
||||
f"├ 📆 За прошлый месяц: <b>{total_payments_last_month} ₽</b>\n"
|
||||
f"└ 🏦 Всего: <b>{total_payments_all_time} ₽</b>\n\n"
|
||||
f"🔥 <b>Горящие лиды</b>: <b>{hot_leads_count}</b> (платили, но не продлили)\n\n"
|
||||
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(text=stats_message, reply_markup=build_stats_kb())
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" not in str(e):
|
||||
logger.error(f"Error in user_stats_menu: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in user_stats_menu: {e}")
|
||||
await callback_query.answer("Произошла ошибка при получении статистики", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_users_csv"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_export_users_csv(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_users_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт пользователей в CSV")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_export_payments_csv(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_payments_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт платежей в CSV")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_export_hot_leads_csv(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_hot_leads_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт горящих лидов")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте 'горящих лидов': {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "stats_export_keys_csv"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_export_keys_csv(callback_query: CallbackQuery, session: Any):
|
||||
kb = build_admin_back_kb("stats")
|
||||
try:
|
||||
export = await export_keys_csv(session)
|
||||
await callback_query.message.answer_document(document=export, caption="📥 Экспорт подписок в CSV")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте подписок в CSV: {e}")
|
||||
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
|
||||
@@ -0,0 +1,3 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from .users_handler import router
|
||||
@@ -0,0 +1,239 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import RENEWAL_PRICES, TOTAL_GB
|
||||
from database import get_clusters
|
||||
from handlers.buttons import BACK
|
||||
|
||||
from ..panel.keyboard import build_admin_back_btn
|
||||
|
||||
|
||||
class AdminUserEditorCallback(CallbackData, prefix="admin_users"):
|
||||
action: str
|
||||
tg_id: int
|
||||
data: str | int | None = None
|
||||
edit: bool = False
|
||||
|
||||
|
||||
class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
|
||||
action: str
|
||||
tg_id: int
|
||||
data: str
|
||||
month: int | None = None
|
||||
edit: bool = False
|
||||
|
||||
|
||||
def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
current_time = datetime.now(tz=timezone.utc)
|
||||
|
||||
builder.button(
|
||||
text="➕ Создать ключ",
|
||||
callback_data=AdminUserEditorCallback(action="users_create_key", tg_id=tg_id).pack(),
|
||||
)
|
||||
|
||||
for record in key_records:
|
||||
email = record["email"]
|
||||
expiry = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
|
||||
days = (expiry - current_time).days
|
||||
builder.button(
|
||||
text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)",
|
||||
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=str(email)).pack(),
|
||||
)
|
||||
|
||||
builder.button(
|
||||
text="✉️ Сообщение", callback_data=AdminUserEditorCallback(action="users_send_message", tg_id=tg_id).pack()
|
||||
)
|
||||
builder.button(
|
||||
text="💸 Изменить баланс",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🤝 Выгрузить рефералов",
|
||||
callback_data=AdminUserEditorCallback(action="users_export_referrals", tg_id=tg_id).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="♻️ Восстановить триал",
|
||||
callback_data=AdminUserEditorCallback(action="users_trial_restore", tg_id=tg_id).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="❌ Удалить клиента", callback_data=AdminUserEditorCallback(action="users_delete_user", tg_id=tg_id).pack()
|
||||
)
|
||||
builder.row(build_editor_btn("🔄 Обновить данные", tg_id, edit=True))
|
||||
builder.row(build_admin_back_btn())
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=BACK, # todo: fix magic text was set
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for month, amount in RENEWAL_PRICES.items():
|
||||
builder.button(
|
||||
text=f"+ {amount}Р ({month} мес.)",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=amount).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text=f"- {amount}Р ({month} мес.)",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=-amount).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="💵 Добавить", callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id).pack()
|
||||
)
|
||||
builder.button(
|
||||
text="💵 Вычесть", callback_data=AdminUserEditorCallback(action="users_balance_take", tg_id=tg_id).pack()
|
||||
)
|
||||
builder.button(
|
||||
text="💵 Установить баланс",
|
||||
callback_data=AdminUserEditorCallback(action="users_balance_set", tg_id=tg_id).pack(),
|
||||
)
|
||||
builder.row(build_editor_back_btn(tg_id, True))
|
||||
builder.adjust(2, 2, 2, 2, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=BACK, # todo: fix magic text was set
|
||||
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email, edit=True).pack(),
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
for month in RENEWAL_PRICES.keys():
|
||||
month = int(month)
|
||||
builder.button(
|
||||
text=f"+ {month} мес.",
|
||||
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email, month=month).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text=f"- {month} мес.",
|
||||
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email, month=-month).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="⏳ Добавить дни", callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email).pack()
|
||||
)
|
||||
builder.button(
|
||||
text="⏳ Вычесть дни", callback_data=AdminUserKeyEditorCallback(action="take", tg_id=tg_id, data=email).pack()
|
||||
)
|
||||
builder.button(
|
||||
text="⏳ Установить дату истечения",
|
||||
callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text=BACK, # todo: fix magic text was set
|
||||
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
|
||||
)
|
||||
builder.adjust(2, 2, 2, 2, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_user_delete_kb(tg_id: int):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="❌ Да, удалить!",
|
||||
callback_data=AdminUserEditorCallback(action="users_delete_user_confirm", tg_id=tg_id).pack(),
|
||||
)
|
||||
builder.row(build_editor_back_btn(tg_id, True))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=BACK, callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack()
|
||||
)
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text="⏳ Время истечения",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_expiry_edit", data=email, tg_id=key_details["tg_id"]
|
||||
).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="🔄 Перевыпустить",
|
||||
callback_data=AdminUserEditorCallback(action="users_update_key", data=email, tg_id=key_details["tg_id"]).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="❌ Удалить",
|
||||
callback_data=AdminUserEditorCallback(action="users_delete_key", data=email, tg_id=key_details["tg_id"]).pack(),
|
||||
)
|
||||
builder.button(
|
||||
text="📊 Трафик",
|
||||
callback_data=AdminUserEditorCallback(action="users_traffic", data=email, tg_id=key_details["tg_id"]).pack(),
|
||||
)
|
||||
if TOTAL_GB > 0:
|
||||
builder.button(
|
||||
text="♻️ Сбросить трафик",
|
||||
callback_data=AdminUserEditorCallback(
|
||||
action="users_reset_traffic", data=email, tg_id=key_details["tg_id"]
|
||||
).pack(),
|
||||
)
|
||||
builder.row(build_editor_back_btn(key_details["tg_id"], True))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=AdminUserEditorCallback(action="users_delete_key_confirm", data=email, tg_id=tg_id).pack(),
|
||||
)
|
||||
)
|
||||
builder.row(build_editor_back_btn(tg_id))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
|
||||
return build_editor_singleton_kb(BACK, tg_id, edit)
|
||||
|
||||
|
||||
def build_editor_singleton_kb(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(build_editor_btn(text, tg_id, edit))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButton:
|
||||
return build_editor_btn(BACK, tg_id, edit)
|
||||
|
||||
|
||||
def build_editor_btn(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardButton:
|
||||
return InlineKeyboardButton(
|
||||
text=text, callback_data=AdminUserEditorCallback(action="users_editor", tg_id=tg_id, edit=edit).pack()
|
||||
)
|
||||
|
||||
|
||||
async def build_cluster_selection_kb(session, tg_id: int, email: str, action: str) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
clusters = await get_clusters(session)
|
||||
|
||||
for cluster_id in clusters:
|
||||
builder.button(text=cluster_id, callback_data=f"{action}|{tg_id}|{email}|{cluster_id}")
|
||||
|
||||
builder.button(text=BACK, callback_data=f"edit_user_key|{tg_id}|{email}")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,947 @@
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import RENEWAL_PRICES, TOTAL_GB, USE_COUNTRY_SELECTION
|
||||
from database import (
|
||||
delete_key,
|
||||
delete_user_data,
|
||||
get_balance,
|
||||
get_client_id_by_email,
|
||||
get_key_details,
|
||||
get_servers,
|
||||
set_user_balance,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
update_trial,
|
||||
)
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.key_utils import (
|
||||
create_key_on_cluster,
|
||||
delete_key_from_cluster,
|
||||
get_user_traffic,
|
||||
renew_key_in_cluster,
|
||||
reset_traffic_in_cluster,
|
||||
update_subscription,
|
||||
)
|
||||
from handlers.utils import generate_random_email, sanitize_key_name
|
||||
from logger import logger
|
||||
from utils.csv_export import export_referrals_csv
|
||||
|
||||
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn, build_admin_back_kb
|
||||
from .keyboard import (
|
||||
AdminUserEditorCallback,
|
||||
AdminUserKeyEditorCallback,
|
||||
build_cluster_selection_kb,
|
||||
build_editor_kb,
|
||||
build_key_delete_kb,
|
||||
build_key_edit_kb,
|
||||
build_user_delete_kb,
|
||||
build_user_edit_kb,
|
||||
build_users_balance_change_kb,
|
||||
build_users_balance_kb,
|
||||
build_users_key_expiry_kb,
|
||||
build_users_key_show_kb,
|
||||
)
|
||||
|
||||
|
||||
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
waiting_for_user_data = State()
|
||||
waiting_for_key_name = State()
|
||||
waiting_for_balance = State()
|
||||
waiting_for_expiry_time = State()
|
||||
waiting_for_message_text = State()
|
||||
selecting_cluster = State()
|
||||
selecting_duration = State()
|
||||
selecting_country = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "search_user"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
|
||||
text = (
|
||||
"<b>🔍 Поиск пользователя</b>"
|
||||
"\n\n📌 Введите ID, Username или перешлите сообщение пользователя."
|
||||
"\n\n🆔 ID - числовой айди"
|
||||
"\n📝 Username - юзернейм пользователя"
|
||||
"\n\n<i>✉️ Для поиска, вы можете просто переслать сообщение от пользователя.</i>"
|
||||
)
|
||||
|
||||
await state.set_state(UserEditorState.waiting_for_user_data)
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb())
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminPanelCallback.filter(F.action == "search_key"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_search_key(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(UserEditorState.waiting_for_key_name)
|
||||
await callback_query.message.edit_text(text="🔑 Введите имя ключа для поиска:", reply_markup=build_admin_back_kb())
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
|
||||
async def handle_key_name_input(message: Message, state: FSMContext, session: Any):
|
||||
kb = build_admin_back_kb()
|
||||
|
||||
if not message.text:
|
||||
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
|
||||
return
|
||||
|
||||
key_name = sanitize_key_name(message.text)
|
||||
key_details = await get_key_details(key_name, session)
|
||||
|
||||
if not key_details:
|
||||
await message.answer(text="🚫 Пользователь с указанным именем ключа не найден.", reply_markup=kb)
|
||||
return
|
||||
|
||||
await process_user_search(message, state, session, key_details["tg_id"])
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
|
||||
async def handle_user_data_input(message: Message, state: FSMContext, session: Any):
|
||||
kb = build_admin_back_kb()
|
||||
|
||||
if message.forward_from:
|
||||
tg_id = message.forward_from.id
|
||||
await process_user_search(message, state, session, tg_id)
|
||||
return
|
||||
|
||||
if not message.text:
|
||||
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
|
||||
return
|
||||
|
||||
if message.text.isdigit():
|
||||
tg_id = int(message.text)
|
||||
else:
|
||||
username = message.text.strip().lstrip("@")
|
||||
username = username.replace("https://t.me/", "")
|
||||
|
||||
user = await session.fetchrow("SELECT tg_id FROM users WHERE username = $1", username)
|
||||
|
||||
if not user:
|
||||
await message.answer(
|
||||
text="🚫 Пользователь с указанным Username не найден!",
|
||||
reply_markup=kb,
|
||||
)
|
||||
return
|
||||
|
||||
tg_id = user["tg_id"]
|
||||
|
||||
await process_user_search(message, state, session, tg_id)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminUserEditorCallback.filter(F.action == "users_send_message"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_send_message(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext
|
||||
):
|
||||
tg_id = callback_data.tg_id
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="✉️ Введите текст сообщения, которое вы хотите отправить пользователю:", reply_markup=build_editor_kb(tg_id)
|
||||
)
|
||||
|
||||
await state.update_data(tg_id=tg_id)
|
||||
await state.set_state(UserEditorState.waiting_for_message_text)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter())
|
||||
async def handle_message_text_input(message: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
tg_id = data.get("tg_id")
|
||||
|
||||
try:
|
||||
await message.bot.send_message(chat_id=tg_id, text=message.text)
|
||||
await message.answer(text="✅ Сообщение успешно отправлено.", reply_markup=build_editor_kb(tg_id))
|
||||
except Exception as e:
|
||||
await message.answer(text=f"❌ Не удалось отправить сообщение: {e}", reply_markup=build_editor_kb(tg_id))
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
AdminUserEditorCallback.filter(F.action == "users_trial_restore"),
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def handle_trial_restore(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
|
||||
):
|
||||
tg_id = callback_data.tg_id
|
||||
|
||||
await update_trial(tg_id, 0, session)
|
||||
await callback_query.message.edit_text(text="✅ Триал успешно восстановлен!", reply_markup=build_editor_kb(tg_id))
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_edit"), IsAdminFilter())
|
||||
async def handle_balance_change(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
|
||||
tg_id = callback_data.tg_id
|
||||
|
||||
records = await session.fetch(
|
||||
"""
|
||||
SELECT amount, payment_system, status, created_at
|
||||
FROM payments
|
||||
WHERE tg_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
tg_id,
|
||||
)
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
balance = int(balance)
|
||||
|
||||
text = (
|
||||
f"<b>💵 Изменение баланса</b>"
|
||||
f"\n\n🆔 ID: <b>{tg_id}</b>"
|
||||
f"\n💰 Баланс: <b>{balance}Р</b>"
|
||||
f"\n📊 Последние операции (5):"
|
||||
)
|
||||
|
||||
if records:
|
||||
for record in records:
|
||||
amount = record["amount"]
|
||||
payment_system = record["payment_system"]
|
||||
status = record["status"]
|
||||
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
text += (
|
||||
f"\n<blockquote>💸 Сумма: {amount} | {payment_system}"
|
||||
f"\n📌 Статус: {status}"
|
||||
f"\n⏳ Дата: {date}</blockquote>"
|
||||
)
|
||||
else:
|
||||
text += "\n <i>🚫 Отсутствуют</i>"
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_users_balance_kb(tg_id))
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_add"), IsAdminFilter())
|
||||
async def handle_balance_add(
|
||||
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_data.tg_id
|
||||
amount = callback_data.data
|
||||
|
||||
if amount is not None:
|
||||
amount = int(amount)
|
||||
if amount >= 0:
|
||||
await update_balance(tg_id, amount, session, is_admin=True)
|
||||
else:
|
||||
current_balance = await get_balance(tg_id)
|
||||
new_balance = max(0, current_balance + amount)
|
||||
await set_user_balance(tg_id, new_balance, session)
|
||||
|
||||
await handle_balance_change(callback_query, callback_data, session)
|
||||
return
|
||||
|
||||
await state.update_data(tg_id=tg_id, op_type="add")
|
||||
await state.set_state(UserEditorState.waiting_for_balance)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Введите сумму, которую хотите добавить на баланс пользователя:",
|
||||
reply_markup=build_users_balance_change_kb(tg_id),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_take"), IsAdminFilter())
|
||||
async def handle_balance_take(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext):
|
||||
tg_id = callback_data.tg_id
|
||||
|
||||
await state.update_data(tg_id=tg_id, op_type="take")
|
||||
await state.set_state(UserEditorState.waiting_for_balance)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Введите сумму, которую хотите вычесть из баланса пользователя:",
|
||||
reply_markup=build_users_balance_change_kb(tg_id),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_set"), IsAdminFilter())
|
||||
async def handle_balance_set(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext):
|
||||
tg_id = callback_data.tg_id
|
||||
|
||||
await state.update_data(tg_id=tg_id, op_type="set")
|
||||
await state.set_state(UserEditorState.waiting_for_balance)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Введите баланс, который хотите установить пользователю:",
|
||||
reply_markup=build_users_balance_change_kb(tg_id),
|
||||
)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_balance, IsAdminFilter())
|
||||
async def handle_balance_input(message: Message, state: FSMContext, session: Any):
|
||||
data = await state.get_data()
|
||||
tg_id = data.get("tg_id")
|
||||
op_type = data.get("op_type")
|
||||
|
||||
if not message.text.isdigit() or int(message.text) < 0:
|
||||
await message.answer(
|
||||
text="🚫 Пожалуйста, введите корректную сумму!", reply_markup=build_users_balance_change_kb(tg_id)
|
||||
)
|
||||
return
|
||||
|
||||
amount = int(message.text)
|
||||
|
||||
if op_type == "add":
|
||||
text = f"✅ К балансу пользователя добавлено <b>{amount}Р</b>"
|
||||
await update_balance(tg_id, amount, session)
|
||||
elif op_type == "take":
|
||||
current_balance = await get_balance(tg_id)
|
||||
new_balance = max(0, current_balance - amount)
|
||||
deducted = current_balance if amount > current_balance else amount
|
||||
text = f"✅ Из баланса пользователя было вычтено <b>{deducted}Р</b>"
|
||||
await set_user_balance(tg_id, new_balance, session)
|
||||
else:
|
||||
text = f"✅ Баланс пользователя изменен на <b>{amount}Р</b>"
|
||||
await set_user_balance(tg_id, amount, session)
|
||||
|
||||
await message.answer(text=text, reply_markup=build_users_balance_change_kb(tg_id))
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_key_edit"), IsAdminFilter())
|
||||
async def handle_key_edit(
|
||||
callback_query: CallbackQuery,
|
||||
callback_data: AdminUserEditorCallback | AdminUserKeyEditorCallback,
|
||||
session: Any,
|
||||
update: bool = False,
|
||||
):
|
||||
email = callback_data.data
|
||||
key_details = await get_key_details(email, session)
|
||||
|
||||
if not key_details:
|
||||
await callback_query.message.edit_text(
|
||||
text="🚫 Информация о ключе не найдена.",
|
||||
reply_markup=build_editor_kb(callback_data.tg_id),
|
||||
)
|
||||
return
|
||||
|
||||
key_value = key_details.get("key") or key_details.get("remnawave_link") or "—"
|
||||
|
||||
text = (
|
||||
f"<b>🔑 Информация о ключе</b>"
|
||||
f"\n\n<code>{key_value}</code>"
|
||||
f"\n\n⏰ Дата истечения: <b>{key_details['expiry_date']} (UTC)</b>"
|
||||
f"\n🌐 Кластер: <b>{key_details['cluster_name']}</b>"
|
||||
f"\n🆔 ID клиента: <b>{key_details['tg_id']}</b>"
|
||||
)
|
||||
|
||||
if not update or not callback_data.edit:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_key_edit_kb(key_details, email))
|
||||
else:
|
||||
await callback_query.message.edit_text(
|
||||
text=text, reply_markup=build_users_key_expiry_kb(callback_data.tg_id, email)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_expiry_edit"), IsAdminFilter())
|
||||
async def handle_change_expiry(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback):
|
||||
tg_id = callback_data.tg_id
|
||||
email = callback_data.data
|
||||
|
||||
await callback_query.message.edit_reply_markup(reply_markup=build_users_key_expiry_kb(tg_id, email))
|
||||
|
||||
|
||||
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "add"), IsAdminFilter())
|
||||
async def handle_expiry_add(
|
||||
callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_data.tg_id
|
||||
email = callback_data.data
|
||||
month = callback_data.month
|
||||
|
||||
key_details = await get_key_details(email, session)
|
||||
|
||||
if not key_details:
|
||||
await callback_query.message.edit_text(
|
||||
text="🚫 Информация о ключе не найдена.",
|
||||
reply_markup=build_editor_kb(tg_id),
|
||||
)
|
||||
return
|
||||
|
||||
if month:
|
||||
await change_expiry_time(key_details["expiry_time"] + month * 30 * 24 * 3600 * 1000, email, session)
|
||||
await handle_key_edit(callback_query, callback_data, session, True)
|
||||
return
|
||||
|
||||
await state.update_data(tg_id=tg_id, email=email, op_type="add")
|
||||
await state.set_state(UserEditorState.waiting_for_expiry_time)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Введите количество дней, которое хотите добавить к времени действия ключа:",
|
||||
reply_markup=build_users_key_show_kb(tg_id, email),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "take"), IsAdminFilter())
|
||||
async def handle_expiry_take(
|
||||
callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext
|
||||
):
|
||||
tg_id = callback_data.tg_id
|
||||
email = callback_data.data
|
||||
|
||||
await state.update_data(tg_id=tg_id, email=email, op_type="take")
|
||||
await state.set_state(UserEditorState.waiting_for_expiry_time)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="✍️ Введите количество дней, которое хотите вычесть из времени действия ключа:",
|
||||
reply_markup=build_users_key_show_kb(tg_id, email),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "set"), IsAdminFilter())
|
||||
async def handle_expiry_set(
|
||||
callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_data.tg_id
|
||||
email = callback_data.data
|
||||
|
||||
key_details = await get_key_details(email, session)
|
||||
|
||||
if not key_details:
|
||||
await callback_query.message.edit_text(
|
||||
text="🚫 Информация о ключе не найдена.",
|
||||
reply_markup=build_editor_kb(tg_id),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(tg_id=tg_id, email=email, op_type="set")
|
||||
await state.set_state(UserEditorState.waiting_for_expiry_time)
|
||||
|
||||
text = (
|
||||
"✍️ Введите новое время действия ключа:"
|
||||
"\n\n📌 Формат: <b>год-месяц-день час:минута</b>"
|
||||
f"\n\n📄 Текущая дата: {datetime.fromtimestamp(key_details['expiry_time'] / 1000).strftime('%Y-%m-%d %H:%M')}"
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(text=text, reply_markup=build_users_key_show_kb(tg_id, email))
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter())
|
||||
async def handle_expiry_time_input(message: Message, state: FSMContext, session: Any):
|
||||
data = await state.get_data()
|
||||
tg_id = data.get("tg_id")
|
||||
email = data.get("email")
|
||||
op_type = data.get("op_type")
|
||||
|
||||
if op_type != "set" and (not message.text.isdigit() or int(message.text) < 0):
|
||||
await message.answer(
|
||||
text="🚫 Пожалуйста, введите корректное количество дней!",
|
||||
reply_markup=build_users_key_show_kb(tg_id, email),
|
||||
)
|
||||
return
|
||||
|
||||
key_details = await get_key_details(email, session)
|
||||
|
||||
if not key_details:
|
||||
await message.answer(
|
||||
text="🚫 Информация о ключе не найдена.",
|
||||
reply_markup=build_editor_kb(tg_id),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
current_expiry_time = datetime.fromtimestamp(key_details["expiry_time"] / 1000, tz=MOSCOW_TZ)
|
||||
|
||||
if op_type == "add":
|
||||
days = int(message.text)
|
||||
new_expiry_time = current_expiry_time + timedelta(days=days)
|
||||
text = f"✅ Ко времени действия ключа добавлено <b>{days} дн.</b>"
|
||||
|
||||
elif op_type == "take":
|
||||
days = int(message.text)
|
||||
new_expiry_time = current_expiry_time - timedelta(days=days)
|
||||
text = f"✅ Из времени действия ключа вычтено <b>{days} дн.</b>"
|
||||
|
||||
else:
|
||||
new_expiry_time = datetime.strptime(message.text, "%Y-%m-%d %H:%M")
|
||||
new_expiry_time = MOSCOW_TZ.localize(new_expiry_time)
|
||||
text = f"✅ Время действия ключа изменено на <b>{message.text} (МСК)</b>"
|
||||
|
||||
new_expiry_timestamp = int(new_expiry_time.timestamp() * 1000)
|
||||
await change_expiry_time(new_expiry_timestamp, email, session)
|
||||
|
||||
except ValueError:
|
||||
text = "🚫 Пожалуйста, используйте корректный формат даты (ГГГГ-ММ-ДД ЧЧ:ММ)!"
|
||||
except Exception as e:
|
||||
text = f"❗ Произошла ошибка во время изменения времени действия ключа: {e}"
|
||||
|
||||
await message.answer(text=text, reply_markup=build_users_key_show_kb(tg_id, email))
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_update_key"), IsAdminFilter())
|
||||
async def handle_update_key(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
|
||||
tg_id = callback_data.tg_id
|
||||
email = callback_data.data
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"📡 Выберите кластер, на котором пересоздать ключ <b>{email}</b>:",
|
||||
reply_markup=await build_cluster_selection_kb(session, tg_id, email, action="confirm_admin_key_reissue"),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_admin_key_reissue|"), IsAdminFilter())
|
||||
async def confirm_admin_key_reissue(callback_query: CallbackQuery, session: Any):
|
||||
_, tg_id, email, cluster_id = callback_query.data.split("|")
|
||||
tg_id = int(tg_id)
|
||||
|
||||
try:
|
||||
await update_subscription(tg_id, email, session, cluster_override=cluster_id)
|
||||
await handle_key_edit(
|
||||
callback_query, AdminUserEditorCallback(tg_id=tg_id, data=email, action="view_key"), session, True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при перевыпуске ключа {email}: {e}")
|
||||
await callback_query.message.answer(f"❗ Ошибка: {e}")
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_key"), IsAdminFilter())
|
||||
async def handle_delete_key(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
|
||||
email = callback_data.data
|
||||
client_id = await session.fetchval("SELECT client_id FROM keys WHERE email = $1", email)
|
||||
|
||||
if client_id is None:
|
||||
await callback_query.message.edit_text(
|
||||
text="🚫 Ключ не найден!", reply_markup=build_editor_kb(callback_data.tg_id)
|
||||
)
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="❓ Вы уверены, что хотите удалить ключ?", reply_markup=build_key_delete_kb(callback_data.tg_id, email)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_key_confirm"), IsAdminFilter())
|
||||
async def handle_delete_key_confirm(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
|
||||
):
|
||||
email = callback_data.data
|
||||
record = await session.fetchrow("SELECT client_id FROM keys WHERE email = $1", email)
|
||||
|
||||
kb = build_editor_kb(callback_data.tg_id)
|
||||
|
||||
if record:
|
||||
client_id = record["client_id"]
|
||||
clusters = await get_servers()
|
||||
|
||||
async def delete_key_from_servers():
|
||||
tasks = []
|
||||
for cluster_name, cluster_servers in clusters.items():
|
||||
for _ in cluster_servers:
|
||||
tasks.append(delete_key_from_cluster(cluster_name, email, client_id))
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
await delete_key_from_servers()
|
||||
await delete_key(client_id, session)
|
||||
|
||||
await callback_query.message.edit_text(text="✅ Ключ успешно удален.", reply_markup=kb)
|
||||
else:
|
||||
await callback_query.message.edit_text(text="🚫 Ключ не найден или уже удален.", reply_markup=kb)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_user"), IsAdminFilter())
|
||||
async def handle_delete_user(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback):
|
||||
tg_id = callback_data.tg_id
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❗️ Вы уверены, что хотите удалить пользователя с ID {tg_id}?", reply_markup=build_user_delete_kb(tg_id)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_user_confirm"), IsAdminFilter())
|
||||
async def handle_delete_user_confirm(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
|
||||
):
|
||||
tg_id = callback_data.tg_id
|
||||
key_records = await session.fetch("SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id)
|
||||
|
||||
async def delete_keys_from_servers():
|
||||
try:
|
||||
tasks = []
|
||||
for email, client_id in key_records:
|
||||
servers = await get_servers()
|
||||
for cluster_id, _cluster in servers.items():
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
await delete_keys_from_servers()
|
||||
|
||||
try:
|
||||
await delete_user_data(session, tg_id)
|
||||
await callback_query.message.edit_text(
|
||||
text=f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=build_editor_kb(callback_data.tg_id)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
text=f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_editor"), IsAdminFilter())
|
||||
async def handle_editor(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
|
||||
):
|
||||
await process_user_search(callback_query.message, state, session, callback_data.tg_id, callback_data.edit)
|
||||
|
||||
|
||||
async def process_user_search(
|
||||
message: types.Message, state: FSMContext, session: Any, tg_id: int, edit: bool = False
|
||||
) -> None:
|
||||
await state.clear()
|
||||
|
||||
user_data = await session.fetchrow(
|
||||
"SELECT username, balance, created_at, updated_at FROM users WHERE tg_id = $1", tg_id
|
||||
)
|
||||
if not user_data:
|
||||
await message.answer(
|
||||
text="🚫 Пользователь с указанным ID не найден!",
|
||||
reply_markup=build_admin_back_kb(),
|
||||
)
|
||||
return
|
||||
|
||||
balance = int(user_data["balance"] or 0)
|
||||
username = user_data["username"]
|
||||
created_at = user_data["created_at"].astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
|
||||
updated_at = user_data["updated_at"].astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
|
||||
|
||||
referral_count = await session.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id)
|
||||
key_records = await session.fetch("SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id)
|
||||
|
||||
text = (
|
||||
f"<b>📊 Информация о пользователе</b>"
|
||||
f"\n\n🆔 ID: <b>{tg_id}</b>"
|
||||
f"\n📄 Логин: <b>@{username}</b>"
|
||||
f"\n📅 Дата регистрации: <b>{created_at}</b>"
|
||||
f"\n🏃 Дата активности: <b>{updated_at}</b>"
|
||||
f"\n💰 Баланс: <b>{balance}</b>"
|
||||
f"\n👥 Количество рефералов: <b>{referral_count}</b>"
|
||||
)
|
||||
|
||||
kb = build_user_edit_kb(tg_id, key_records)
|
||||
|
||||
if edit:
|
||||
try:
|
||||
await message.edit_text(text=text, reply_markup=kb)
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
else:
|
||||
await message.answer(text=text, reply_markup=kb)
|
||||
|
||||
|
||||
async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exception | None:
|
||||
client_id = await get_client_id_by_email(email)
|
||||
|
||||
if client_id is None:
|
||||
return ValueError(f"User with email {email} was not found")
|
||||
|
||||
server_id = await session.fetchrow("SELECT server_id FROM keys WHERE client_id = $1", client_id)
|
||||
|
||||
if not server_id:
|
||||
return ValueError(f"User with client_id {server_id} was not found")
|
||||
|
||||
clusters = await get_servers()
|
||||
added_days = max((expiry_time - int(time.time() * 1000)) / (1000 * 86400), 1)
|
||||
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
|
||||
|
||||
async def update_key_on_all_servers():
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
renew_key_in_cluster(
|
||||
cluster_name,
|
||||
email,
|
||||
client_id,
|
||||
expiry_time,
|
||||
total_gb=total_gb,
|
||||
)
|
||||
)
|
||||
for cluster_name in clusters
|
||||
]
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
await update_key_on_all_servers()
|
||||
await update_key_expiry(client_id, expiry_time, session)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_traffic"), IsAdminFilter())
|
||||
async def handle_user_traffic(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
|
||||
):
|
||||
"""
|
||||
Обработчик кнопки "📊 Трафик".
|
||||
Получает трафик пользователя и отправляет администратору.
|
||||
"""
|
||||
tg_id = callback_data.tg_id
|
||||
email = callback_data.data
|
||||
|
||||
await callback_query.message.edit_text("⏳ Получаем данные о трафике, пожалуйста, подождите...")
|
||||
|
||||
traffic_data = await get_user_traffic(session, tg_id, email)
|
||||
|
||||
if traffic_data["status"] == "error":
|
||||
await callback_query.message.edit_text(traffic_data["message"], reply_markup=build_editor_kb(tg_id, True))
|
||||
return
|
||||
|
||||
total_traffic = 0
|
||||
|
||||
result_text = f"📊 <b>Трафик подписки {email}:</b>\n\n"
|
||||
|
||||
for server, traffic in traffic_data["traffic"].items():
|
||||
if isinstance(traffic, str):
|
||||
result_text += f"❌ {server}: {traffic}\n"
|
||||
else:
|
||||
result_text += f"🌍 {server}: <b>{traffic} ГБ</b>\n"
|
||||
total_traffic += traffic
|
||||
|
||||
result_text += f"\n🔢 <b>Общий трафик:</b> {total_traffic:.2f} ГБ"
|
||||
|
||||
await callback_query.message.edit_text(result_text, reply_markup=build_editor_kb(tg_id, True))
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_trials"), IsAdminFilter())
|
||||
async def confirm_restore_trials(callback_query: types.CallbackQuery):
|
||||
"""
|
||||
Меню подтверждения перед восстановлением пробников.
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="✅ Подтвердить", callback_data=AdminPanelCallback(action="confirm_restore_trials").pack())
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="⚠ Вы уверены, что хотите восстановить пробники для пользователей? \n\n"
|
||||
"Только для тех, у кого нет активной подписки!",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminPanelCallback.filter(F.action == "confirm_restore_trials"), IsAdminFilter())
|
||||
async def restore_trials(callback_query: types.CallbackQuery, session: Any):
|
||||
"""
|
||||
Восстанавливает пробники для пользователей, у которых нет активной подписки.
|
||||
"""
|
||||
query = """
|
||||
UPDATE users
|
||||
SET trial = 0
|
||||
WHERE tg_id IN (
|
||||
SELECT u.tg_id
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT tg_id
|
||||
FROM keys
|
||||
WHERE expiry_time > EXTRACT(EPOCH FROM NOW()) * 1000
|
||||
) k ON u.tg_id = k.tg_id
|
||||
WHERE k.tg_id IS NULL AND u.trial != 0
|
||||
)
|
||||
"""
|
||||
await session.execute(query)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="✅ Пробники успешно восстановлены для пользователей без активных подписок.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_export_referrals"), IsAdminFilter())
|
||||
async def handle_users_export_referrals(
|
||||
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
|
||||
):
|
||||
"""
|
||||
Обработчик: получает tg_id реферера из callback_data,
|
||||
вызывает export_referrals_csv и отправляет файл или отвечает,
|
||||
что рефералов нет.
|
||||
"""
|
||||
referrer_tg_id = callback_data.tg_id
|
||||
|
||||
csv_file = await export_referrals_csv(referrer_tg_id, session)
|
||||
|
||||
if csv_file is None:
|
||||
await callback_query.message.answer("У пользователя нет рефералов.")
|
||||
return
|
||||
|
||||
await callback_query.message.answer_document(
|
||||
document=csv_file, caption=f"Список рефералов для пользователя {referrer_tg_id}."
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_create_key"), IsAdminFilter())
|
||||
async def handle_create_key_start(
|
||||
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_data.tg_id
|
||||
await state.update_data(tg_id=tg_id)
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
await state.set_state(UserEditorState.selecting_country)
|
||||
|
||||
rows = await session.fetch("SELECT DISTINCT server_name FROM servers ORDER BY server_name")
|
||||
countries = [row["server_name"] for row in rows]
|
||||
|
||||
if not countries:
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Нет доступных стран для создания ключа.", reply_markup=build_editor_kb(tg_id)
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for country in countries:
|
||||
builder.button(text=country, callback_data=country)
|
||||
builder.adjust(1)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
"🌍 <b>Выберите страну для создания ключа:</b>", reply_markup=builder.as_markup()
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(UserEditorState.selecting_cluster)
|
||||
|
||||
servers = await get_servers(session)
|
||||
cluster_names = list(servers.keys())
|
||||
|
||||
if not cluster_names:
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Нет доступных кластеров для создания ключа.", reply_markup=build_editor_kb(tg_id)
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for cluster in cluster_names:
|
||||
builder.button(text=f"🌐 {cluster}", callback_data=cluster)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
"🌐 <b>Выберите кластер для создания ключа:</b>", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(UserEditorState.selecting_country, IsAdminFilter())
|
||||
async def handle_create_key_country(callback_query: CallbackQuery, state: FSMContext):
|
||||
country = callback_query.data
|
||||
await state.update_data(country=country)
|
||||
await state.set_state(UserEditorState.selecting_duration)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for months, _ in RENEWAL_PRICES.items():
|
||||
builder.button(text=f"{months} мес.", callback_data=str(months))
|
||||
builder.adjust(1)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"🕒 <b>Выберите срок действия ключа для страны {country}:</b>", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(UserEditorState.selecting_cluster, IsAdminFilter())
|
||||
async def handle_create_key_cluster(callback_query: CallbackQuery, state: FSMContext):
|
||||
cluster_name = callback_query.data
|
||||
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
await state.set_state(UserEditorState.selecting_duration)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for months, _ in RENEWAL_PRICES.items():
|
||||
builder.button(text=f"{months} мес.", callback_data=str(months))
|
||||
builder.adjust(1)
|
||||
builder.row(build_admin_back_btn())
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"🕒 <b>Выберите срок действия ключа для кластера {cluster_name}:</b>", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(UserEditorState.selecting_duration, IsAdminFilter())
|
||||
async def handle_create_key_duration(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
try:
|
||||
months = int(callback_query.data)
|
||||
data = await state.get_data()
|
||||
tg_id = data["tg_id"]
|
||||
|
||||
client_id = str(uuid.uuid4())
|
||||
email = generate_random_email()
|
||||
expiry = datetime.now(tz=timezone.utc) + timedelta(days=30 * months)
|
||||
expiry_ms = int(expiry.timestamp() * 1000)
|
||||
|
||||
if USE_COUNTRY_SELECTION and "country" in data:
|
||||
country = data["country"]
|
||||
await create_key_on_cluster(country, tg_id, client_id, email, expiry_ms, plan=months, session=session)
|
||||
|
||||
await state.clear()
|
||||
await callback_query.message.edit_text(
|
||||
f"✅ Ключ успешно создан для страны <b>{country}</b> на {months} мес.",
|
||||
reply_markup=build_editor_kb(tg_id),
|
||||
)
|
||||
|
||||
elif "cluster_name" in data:
|
||||
cluster_name = data["cluster_name"]
|
||||
await create_key_on_cluster(cluster_name, tg_id, client_id, email, expiry_ms, plan=months, session=session)
|
||||
|
||||
await state.clear()
|
||||
await callback_query.message.edit_text(
|
||||
f"✅ Ключ успешно создан в кластере <b>{cluster_name}</b> на {months} мес.",
|
||||
reply_markup=build_editor_kb(tg_id),
|
||||
)
|
||||
|
||||
else:
|
||||
await callback_query.message.edit_text("❌ Не удалось определить источник — страна или кластер.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании ключа: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Не удалось создать ключ. Попробуйте позже.", reply_markup=build_editor_kb(data.get("tg_id", 0))
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_reset_traffic"), IsAdminFilter())
|
||||
async def handle_reset_traffic(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
|
||||
tg_id = callback_data.tg_id
|
||||
email = callback_data.data
|
||||
|
||||
record = await session.fetchrow(
|
||||
"SELECT server_id, client_id FROM keys WHERE tg_id = $1 AND email = $2",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
|
||||
if not record:
|
||||
await callback_query.message.edit_text("❌ Ключ не найден в базе данных.", reply_markup=build_editor_kb(tg_id))
|
||||
return
|
||||
|
||||
cluster_id = record["server_id"]
|
||||
|
||||
try:
|
||||
await reset_traffic_in_cluster(cluster_id, email)
|
||||
await callback_query.message.edit_text(
|
||||
f"✅ Трафик для ключа <b>{email}</b> успешно сброшен.", reply_markup=build_editor_kb(tg_id)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сбросе трафика: {e}")
|
||||
await callback_query.message.edit_text(
|
||||
"❌ Произошла ошибка при сбросе трафика. Попробуйте позже.", reply_markup=build_editor_kb(tg_id)
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
# Основные кнопки меню:
|
||||
|
||||
MAIN_MENU = "👤 Личный кабинет"
|
||||
BACK = "⬅️ Назад"
|
||||
SUPPORT = "💬 Поддержка"
|
||||
CHANNEL = "📢 Канал"
|
||||
ABOUT_VPN = "💬 О сервисе"
|
||||
APPLY = "✅ Подтвердить"
|
||||
CANCEL = "❌ Отмена"
|
||||
|
||||
# Профиль
|
||||
|
||||
ADD_SUB = "➕ Подписка"
|
||||
MY_SUBS = "📱 Мои подписки"
|
||||
BALANCE = "💰 Баланс"
|
||||
INVITE = "👥 Пригласить"
|
||||
GIFTS = "🎁 Подарить"
|
||||
INSTRUCTIONS = "📘 Инструкции"
|
||||
TOP_FIVE = "🏆 Топ-5"
|
||||
TRIAL_SUB = "🎁 Пробная подписка"
|
||||
|
||||
# Меню Оплат и баланса
|
||||
|
||||
BALANCE_HISTORY = "📊 История пополнения"
|
||||
PAYMENT = "💳 Пополнить баланс"
|
||||
COUPON = "🎟️ Активировать купон"
|
||||
COUPON_RESTART = "🎟️ Попробовать другой купон"
|
||||
YOOKASSA = "💳 ЮКасса: быстрая оплата"
|
||||
YOOMONEY = "💳 ЮМани: перевод по карте"
|
||||
CRYPTOBOT = "💰 CryptoBot: криптовалюта"
|
||||
STARS = "⭐ Оплата Звездами"
|
||||
ROBOKASSA = "⭐ RoboKassa"
|
||||
|
||||
|
||||
# Подарки
|
||||
|
||||
GIFT = "🎁 Подарить подписку"
|
||||
MY_GIFTS = "🎁 Мои подарки"
|
||||
GIFTS_MENU = "В меню подарков"
|
||||
SHARE_GIFT = "🎁 Поделиться подарком"
|
||||
GET_GIFT = "🎁 Получить подарок"
|
||||
|
||||
# Создание и подключение ключей
|
||||
|
||||
DOWNLOAD_IOS_BUTTON = "🍏 Скачать iOS"
|
||||
DOWNLOAD_ANDROID_BUTTON = "🤖 Скачать Android"
|
||||
DOWNLOAD_MACOS_BUTTON = "🍏 Скачать macOS"
|
||||
DOWNLOAD_PC_BUTTON = "💻 Скачать Windows"
|
||||
IMPORT_IOS = "🍏 Подключить"
|
||||
IMPORT_ANDROID = "🤖 Подключить"
|
||||
PC_BUTTON = "💻 Компьютеры"
|
||||
TV_BUTTON = "📺 Андроид TV"
|
||||
CONNECT_PHONE = "📱 Подключить телефон"
|
||||
CONNECT_DEVICE = "📲 Подключить устройство"
|
||||
CONNECT_WINDOWS_BUTTON = "💻 Подключить"
|
||||
CONNECT_MACOS_BUTTON = "🍏 Подключить"
|
||||
ALIAS = "✏️"
|
||||
UNFREEZE = "🟢 Разморозить подписку"
|
||||
FREEZE = "🛑 Заморозить подписку"
|
||||
RENEW = "⏳ Продлить"
|
||||
RENEW_FULL = "⏳ Продлить подписку"
|
||||
DELETE = "❌ Удалить"
|
||||
CHANGE_LOCATION = "🌍 Сменить локацию"
|
||||
QR = "📷 Показать QR-код"
|
||||
IPHONE = "🍏 Айфон"
|
||||
ANDROID = "🤖 Андроид"
|
||||
PC = "💻 Компьютер"
|
||||
PC_PC = "💻 Windows"
|
||||
PC_MACOS = "🍏 macOS"
|
||||
TV = "📺 Телевизор"
|
||||
ROUTER = "📶 Роутер"
|
||||
MANUAL_INSTRUCTIONS = "📖 Ручная установка"
|
||||
RENEW_KEY = "🔄 Продлить подписку"
|
||||
TV_CONTINUE = "▶ Продолжить"
|
||||
TV_INSTRUCTIONS = "📖 Полная инструкция"
|
||||
|
||||
|
||||
# Кнопки касс
|
||||
|
||||
PAY = "Пополнить"
|
||||
PAY_2 = "Оплатить"
|
||||
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
|
||||
STARS_BOT = "🤖 Бот для покупки звезд"
|
||||
@@ -0,0 +1,77 @@
|
||||
import random
|
||||
import secrets
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.texts import CAPTCHA_EMOJIS, CAPTCHA_PROMPT_MSG
|
||||
from logger import logger
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
async def generate_captcha(message: Message, state: FSMContext):
|
||||
correct_emoji, correct_text = secrets.choice(list(CAPTCHA_EMOJIS.items()))
|
||||
wrong_emojis = random.sample([e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3)
|
||||
|
||||
all_emojis = [correct_emoji] + wrong_emojis
|
||||
random.shuffle(all_emojis)
|
||||
|
||||
await state.update_data(
|
||||
correct_emoji=correct_emoji,
|
||||
message_id=message.message_id,
|
||||
chat_id=message.chat.id,
|
||||
original_text=message.text,
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for emoji in all_emojis:
|
||||
builder.button(text=emoji, callback_data=f"captcha_{emoji}")
|
||||
builder.adjust(2, 2)
|
||||
|
||||
return {
|
||||
"text": CAPTCHA_PROMPT_MSG.format(correct_text=correct_text),
|
||||
"markup": builder.as_markup(),
|
||||
}
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("captcha_"))
|
||||
async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool):
|
||||
from handlers.start import start_command
|
||||
|
||||
selected_emoji = callback.data.split("captcha_")[1]
|
||||
state_data = await state.get_data()
|
||||
correct_emoji = state_data.get("correct_emoji")
|
||||
message_id = state_data.get("message_id")
|
||||
chat_id = state_data.get("chat_id")
|
||||
|
||||
if not message_id or not chat_id:
|
||||
target_message = callback.message
|
||||
else:
|
||||
try:
|
||||
target_message = await callback.bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=callback.message.text,
|
||||
)
|
||||
except Exception:
|
||||
target_message = callback.message
|
||||
|
||||
if selected_emoji == correct_emoji:
|
||||
logger.info(f"Пользователь {callback.message.chat.id} успешно прошел капчу")
|
||||
await start_command(target_message, state, session, admin, captcha=False)
|
||||
else:
|
||||
logger.warning(f"Пользователь {callback.message.chat.id} неверно ответил на капчу")
|
||||
captcha = await generate_captcha(target_message, state)
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=captcha["text"],
|
||||
reply_markup=captcha["markup"],
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
from aiogram import Router, types
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from handlers.start import start_command
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message(Command("start"))
|
||||
async def handle_start(message: types.Message, state: FSMContext):
|
||||
await start_command(message)
|
||||
|
||||
|
||||
@router.message(Command("menu"))
|
||||
async def handle_menu(message: types.Message, state: FSMContext):
|
||||
await start_command(message)
|
||||
+187
-87
@@ -1,128 +1,228 @@
|
||||
from datetime import datetime
|
||||
import html
|
||||
|
||||
import asyncpg
|
||||
from aiogram import F, Router, types
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import DATABASE_URL
|
||||
from database import update_balance
|
||||
from config import ADMIN_ID
|
||||
from database import (
|
||||
add_user,
|
||||
check_coupon_usage,
|
||||
check_user_exists,
|
||||
create_coupon_usage,
|
||||
get_coupon_by_code,
|
||||
get_keys,
|
||||
update_balance,
|
||||
update_coupon_usage_count,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.buttons import MAIN_MENU
|
||||
from handlers.keys.key_utils import renew_key_in_cluster
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.texts import (
|
||||
COUPON_ALREADY_USED_MSG,
|
||||
COUPON_INPUT_PROMPT,
|
||||
COUPON_NOT_FOUND_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, format_days
|
||||
from logger import logger
|
||||
|
||||
|
||||
class CouponActivationState(StatesGroup):
|
||||
waiting_for_coupon_code = State()
|
||||
waiting_for_key_selection = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "activate_coupon")
|
||||
async def handle_activate_coupon(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
@router.message(F.text == "/activate_coupon")
|
||||
async def handle_activate_coupon(callback_query_or_message: Message | CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬅️ Вернуться в профиль", callback_data="view_profile")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
"<b>Введите код купона:</b>\n\n"
|
||||
"Пожалуйста, введите действующий код купона, который вы хотите активировать.",
|
||||
parse_mode="HTML",
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
else:
|
||||
target_message = callback_query_or_message
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=COUPON_INPUT_PROMPT,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
await state.set_state(CouponActivationState.waiting_for_coupon_code)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.message(CouponActivationState.waiting_for_coupon_code)
|
||||
async def process_coupon_code(message: types.Message, state: FSMContext):
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
async def process_coupon_code(message: Message, state: FSMContext, session: Any):
|
||||
coupon_code = message.text.strip()
|
||||
activation_result = await activate_coupon(message.from_user.id, coupon_code)
|
||||
await activate_coupon(message, state, session, coupon_code=coupon_code)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="view_profile")
|
||||
)
|
||||
|
||||
markup = builder.as_markup()
|
||||
async def activate_coupon(message: Message, state: FSMContext, session: Any, coupon_code: str, admin: bool = False):
|
||||
logger.info(f"Активация купона: {coupon_code}")
|
||||
coupon_record = await get_coupon_by_code(coupon_code, session)
|
||||
|
||||
await message.answer(activation_result, reply_markup=markup, parse_mode="HTML")
|
||||
if not coupon_record:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input"))
|
||||
await message.answer(COUPON_NOT_FOUND_MSG, reply_markup=builder.as_markup())
|
||||
return
|
||||
|
||||
if coupon_record["usage_count"] >= coupon_record["usage_limit"] or coupon_record["is_used"]:
|
||||
await message.answer("❌ Лимит активаций купона исчерпан.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
user_id = message.chat.id
|
||||
|
||||
usage = await check_coupon_usage(coupon_record["id"], user_id, session)
|
||||
if usage:
|
||||
await message.answer(COUPON_ALREADY_USED_MSG)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
user_exists = await check_user_exists(user_id)
|
||||
if not user_exists:
|
||||
from_user = message.from_user
|
||||
await add_user(
|
||||
tg_id=from_user.id,
|
||||
username=from_user.username,
|
||||
first_name=from_user.first_name,
|
||||
last_name=from_user.last_name,
|
||||
language_code=from_user.language_code,
|
||||
is_bot=from_user.is_bot,
|
||||
session=session,
|
||||
)
|
||||
|
||||
if coupon_record["amount"] > 0:
|
||||
try:
|
||||
await update_balance(user_id, coupon_record["amount"], session, skip_referral=True)
|
||||
await update_coupon_usage_count(coupon_record["id"], session)
|
||||
await create_coupon_usage(coupon_record["id"], user_id, session)
|
||||
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon_record['amount']} рублей.")
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при активации купона на баланс: {e}")
|
||||
await message.answer("❌ Ошибка при активации купона.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if coupon_record["days"] is not None and coupon_record["days"] > 0:
|
||||
try:
|
||||
keys = await get_keys(user_id, session)
|
||||
active_keys = [k for k in keys if not k["is_frozen"]]
|
||||
|
||||
if not active_keys:
|
||||
await message.answer("❌ У вас нет активных подписок для продления.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
|
||||
|
||||
for key in active_keys:
|
||||
alias = key.get("alias")
|
||||
email = key["email"]
|
||||
client_id = key["client_id"]
|
||||
expiry_time = key.get("expiry_time")
|
||||
|
||||
key_display = html.escape(alias.strip() if alias else email)
|
||||
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
|
||||
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
|
||||
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon_record['id']}")
|
||||
|
||||
response_message += "</blockquote>"
|
||||
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
|
||||
builder.adjust(1)
|
||||
|
||||
await message.answer(response_message, reply_markup=builder.as_markup())
|
||||
await state.set_state(CouponActivationState.waiting_for_key_selection)
|
||||
await state.update_data(coupon_id=coupon_record["id"], user_id=user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке купона на дни: {e}")
|
||||
await message.answer("❌ Ошибка при активации купона.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await message.answer("❌ Купон недействителен (нет суммы или дней).")
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def activate_coupon(user_id: int, coupon_code: str):
|
||||
"""Функция для активации купона"""
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
@router.callback_query(F.data.startswith("extend_key|"))
|
||||
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
|
||||
parts = callback_query.data.split("|")
|
||||
client_id = parts[1]
|
||||
coupon_id = int(parts[2])
|
||||
|
||||
try:
|
||||
coupon_record = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, usage_limit, usage_count, is_used, amount
|
||||
FROM coupons
|
||||
WHERE code = $1 AND (usage_count < usage_limit OR usage_limit = 0) AND is_used = FALSE
|
||||
""",
|
||||
coupon_code,
|
||||
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
|
||||
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
|
||||
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
usage = await check_coupon_usage(coupon_id, callback_query.from_user.id, session)
|
||||
if usage:
|
||||
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
key = await session.fetchrow(
|
||||
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2", callback_query.from_user.id, client_id
|
||||
)
|
||||
if not key or key["is_frozen"]:
|
||||
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not coupon_record:
|
||||
return "<b>❌ Купон не найден</b> или его использование ограничено. Пожалуйста, проверьте код и попробуйте снова."
|
||||
now_ms = int(datetime.now().timestamp() * 1000)
|
||||
current_expiry = key["expiry_time"]
|
||||
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
|
||||
|
||||
usage_exists = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1 FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2
|
||||
""",
|
||||
coupon_record["id"],
|
||||
user_id,
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=key["server_id"], email=key["email"], client_id=client_id, new_expiry_time=new_expiry, total_gb=0
|
||||
)
|
||||
await update_key_expiry(client_id, new_expiry, session)
|
||||
|
||||
if usage_exists:
|
||||
return "<b>❌ Вы уже активировали этот купон.</b> Купоны могут быть активированы только один раз."
|
||||
|
||||
coupon_amount = coupon_record["amount"]
|
||||
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE coupons
|
||||
SET usage_count = usage_count + 1,
|
||||
is_used = CASE WHEN usage_count + 1 >= usage_limit AND usage_limit > 0 THEN TRUE ELSE FALSE END
|
||||
WHERE id = $1
|
||||
""",
|
||||
coupon_record["id"],
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO coupon_usages (coupon_id, user_id, used_at)
|
||||
VALUES ($1, $2, $3)
|
||||
""",
|
||||
coupon_record["id"],
|
||||
user_id,
|
||||
datetime.utcnow(),
|
||||
)
|
||||
|
||||
await update_balance(user_id, coupon_amount)
|
||||
return f"<b>✅ Купон успешно активирован!</b>\n\nНа ваш баланс добавлено <b>{coupon_amount} рублей</b>."
|
||||
await update_coupon_usage_count(coupon["id"], session)
|
||||
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
|
||||
|
||||
alias = key.get("alias") or key["email"]
|
||||
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime(
|
||||
"%d.%m.%y, %H:%M"
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
|
||||
)
|
||||
await process_callback_view_profile(callback_query.message, state, admin)
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при активации купона: {e}")
|
||||
return (
|
||||
"<b>⚠️ Произошла ошибка при активации купона.</b>\nПопробуйте ещё раз позже."
|
||||
)
|
||||
logger.error(f"Ошибка при продлении ключа: {e}")
|
||||
await callback_query.message.edit_text("❌ Ошибка при активации купона.")
|
||||
await state.clear()
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@router.callback_query(F.data == "cancel_coupon_activation")
|
||||
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
|
||||
await callback_query.message.edit_text("⚠️ Активация купона отменена.")
|
||||
await process_callback_view_profile(callback_query.message, state, admin)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "exit_coupon_input")
|
||||
async def handle_exit_coupon_input(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
is_admin = callback_query.from_user.id in ADMIN_ID
|
||||
await process_callback_view_profile(callback_query.message, state, admin=is_admin)
|
||||
|
||||
+37
-66
@@ -1,13 +1,15 @@
|
||||
from aiogram import F, Router, types
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, LabeledPrice, PreCheckoutQuery
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, LabeledPrice, Message, PreCheckoutQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import RUB_TO_XTR
|
||||
from handlers.buttons import BACK, MAIN_MENU
|
||||
from logger import logger
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
class DonateState(StatesGroup):
|
||||
entering_donate_amount = State()
|
||||
@@ -19,60 +21,53 @@ router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "donate")
|
||||
async def process_donate(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить сообщение: {e}")
|
||||
async def process_donate(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
|
||||
text="💰 Ввести сумму доната",
|
||||
callback_data="enter_custom_donate_amount",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 Ввести сумму доната", callback_data="enter_custom_donate_amount"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_profile"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=callback_query.from_user.id,
|
||||
text="🌟 Поддержите наш проект!\n\n"
|
||||
"Каждый донат помогает развивать и улучшать сервис. "
|
||||
"Мы ценим вашу поддержку и работаем над тем, чтобы сделать наш продукт еще лучше. 💡",
|
||||
text = (
|
||||
"🌟 Поддержите наш проект! 💪\n\n"
|
||||
"💖 Каждый донат помогает развивать и улучшать сервис. "
|
||||
"🤝 Мы ценим вашу поддержку и работаем над тем, чтобы сделать наш продукт еще лучше. 🚀💡"
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_donate_amount")
|
||||
async def process_enter_donate_amount(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
await callback_query.message.edit_text(f"💸 Введите сумму доната в рублях:")
|
||||
async def process_enter_donate_amount(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
|
||||
text = "💸 Введите сумму доната в рублях:"
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.set_state(DonateState.entering_donate_amount)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.message(DonateState.entering_donate_amount)
|
||||
async def process_donate_amount_input(message: types.Message, state: FSMContext):
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
async def process_donate_amount_input(message: Message, state: FSMContext):
|
||||
if message.text.isdigit():
|
||||
amount = int(message.text)
|
||||
if amount // RUB_TO_XTR <= 0:
|
||||
await message.answer(
|
||||
f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:"
|
||||
)
|
||||
await message.answer(f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:")
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
@@ -81,7 +76,7 @@ async def process_donate_amount_input(message: types.Message, state: FSMContext)
|
||||
try:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="Задонатить", pay=True))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
|
||||
|
||||
await message.answer_invoice(
|
||||
title=f"Донат проекту {amount} рублей",
|
||||
@@ -95,7 +90,6 @@ async def process_donate_amount_input(message: types.Message, state: FSMContext)
|
||||
await state.set_state(DonateState.waiting_for_donate_payment)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании доната: {e}")
|
||||
await message.answer("Произошла ошибка при создании доната.")
|
||||
else:
|
||||
await message.answer("Некорректная сумма. Пожалуйста, введите сумму еще раз:")
|
||||
|
||||
@@ -106,39 +100,16 @@ async def on_pre_checkout_query(pre_checkout_query: PreCheckoutQuery):
|
||||
|
||||
|
||||
@router.message(F.successful_payment, DonateState.waiting_for_donate_payment)
|
||||
async def on_successful_donate(message: types.Message, state: FSMContext):
|
||||
async def on_successful_donate(message: Message, state: FSMContext):
|
||||
try:
|
||||
user_id = int(message.from_user.id)
|
||||
amount = float(message.successful_payment.invoice_payload.split("_")[0])
|
||||
logger.debug(f"Donate succeeded for user_id: {user_id}, amount: {amount}")
|
||||
|
||||
state_data = await state.get_data()
|
||||
previous_message_id = state_data.get("last_message_id")
|
||||
|
||||
if previous_message_id:
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=user_id, message_id=previous_message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="Вернуться в профиль", callback_data="view_profile"
|
||||
)
|
||||
)
|
||||
|
||||
sent_message = await bot.send_message(
|
||||
chat_id=user_id,
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await message.answer(
|
||||
text=f"🙏 Спасибо за донат {amount} рублей! Ваша поддержка очень важна для нас. 💖",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.update_data(last_message_id=sent_message.message_id)
|
||||
await state.clear()
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Ошибка конвертации user_id или amount: {e}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .instructions import router as instructions_router
|
||||
|
||||
|
||||
router = Router(name="instructions_main_router")
|
||||
|
||||
router.include_routers(
|
||||
instructions_router,
|
||||
)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
@@ -1,77 +1,180 @@
|
||||
import os
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
Message,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
CONNECT_MACOS,
|
||||
CONNECT_WINDOWS,
|
||||
DOWNLOAD_MACOS,
|
||||
DOWNLOAD_PC,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from database import get_key_details
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
CONNECT_MACOS_BUTTON,
|
||||
CONNECT_WINDOWS_BUTTON,
|
||||
DOWNLOAD_MACOS_BUTTON,
|
||||
DOWNLOAD_PC_BUTTON,
|
||||
MAIN_MENU,
|
||||
PC_MACOS,
|
||||
PC_PC,
|
||||
SUPPORT,
|
||||
TV_CONTINUE,
|
||||
TV_INSTRUCTIONS,
|
||||
)
|
||||
from handlers.texts import (
|
||||
CHOOSE_DEVICE_TEXT,
|
||||
CONNECT_TV_TEXT,
|
||||
INSTRUCTIONS,
|
||||
INSTRUCTION_MACOS,
|
||||
INSTRUCTION_PC,
|
||||
KEY_MESSAGE,
|
||||
SUBSCRIPTION_DETAILS_TEXT,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
|
||||
from bot import bot
|
||||
from config import CONNECT_WINDOWS, SUPPORT_CHAT_URL
|
||||
from handlers.texts import INSTRUCTION_PC, INSTRUCTIONS, KEY_MESSAGE
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
async def send_instructions(callback_query: types.CallbackQuery):
|
||||
await callback_query.message.delete()
|
||||
|
||||
@router.callback_query(F.data == "instructions")
|
||||
@router.message(F.text == "/instructions")
|
||||
async def send_instructions(callback_query_or_message: CallbackQuery | Message):
|
||||
instructions_message = INSTRUCTIONS
|
||||
image_path = os.path.join("img", "instructions.jpg")
|
||||
|
||||
image_path = os.path.join(os.path.dirname(__file__), "instructions.jpg")
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if not os.path.isfile(image_path):
|
||||
await callback_query.message.answer("Файл изображения не найден.")
|
||||
await callback_query.answer()
|
||||
return
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
else:
|
||||
target_message = callback_query_or_message
|
||||
|
||||
back_button = InlineKeyboardButton(
|
||||
text="⬅️ Вернуться в профиль", callback_data="view_profile"
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=instructions_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
with open(image_path, "rb") as image_from_buffer:
|
||||
await callback_query.message.answer_photo(
|
||||
BufferedInputFile(image_from_buffer.read(), filename="instructions.jpg"),
|
||||
caption=instructions_message,
|
||||
parse_mode="Markdown",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_pc|"))
|
||||
async def process_connect_pc(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
key = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
async def process_connect_pc(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
record = await get_key_details(key_name, session)
|
||||
if not record:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="❌ <b>Ключ не найден. Проверьте имя ключа.</b> 🔍",
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
return
|
||||
|
||||
key_message = KEY_MESSAGE.format(key)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
|
||||
instruction_message = f"{key_message}{INSTRUCTION_PC}"
|
||||
|
||||
connect_windows_button = types.InlineKeyboardButton(
|
||||
text="💻 Подключить Windows", url=f"{CONNECT_WINDOWS}{key}"
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CHOOSE_DEVICE_TEXT,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
support_button = types.InlineKeyboardButton(
|
||||
text="🆘 Поддержка", url=f"{SUPPORT_CHAT_URL}"
|
||||
|
||||
@router.callback_query(F.data.startswith("windows_menu|"))
|
||||
async def process_windows_menu(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
record = await get_key_details(key_name, session)
|
||||
key = record["key"]
|
||||
key_message_text = KEY_MESSAGE.format(key)
|
||||
instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=DOWNLOAD_PC_BUTTON, url=DOWNLOAD_PC))
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{key}"))
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=instruction_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text="🔙 Назад в профиль", callback_data="view_profile"
|
||||
|
||||
@router.callback_query(F.data.startswith("macos_menu|"))
|
||||
async def process_macos_menu(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
record = await get_key_details(key_name, session)
|
||||
key = record["key"]
|
||||
key_message_text = KEY_MESSAGE.format(key)
|
||||
instruction_message = f"{key_message_text}{INSTRUCTION_MACOS}"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=DOWNLOAD_MACOS_BUTTON, url=DOWNLOAD_MACOS))
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{key}"))
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=instruction_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
inline_keyboard = [[connect_windows_button], [support_button], [back_button]]
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=inline_keyboard)
|
||||
|
||||
await bot.send_message(
|
||||
tg_id, instruction_message, reply_markup=keyboard, parse_mode="HTML"
|
||||
@router.callback_query(F.data.startswith("connect_tv|"))
|
||||
async def process_connect_tv(callback_query: CallbackQuery):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CONNECT_TV_TEXT,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(F.data.startswith("continue_tv|"))
|
||||
async def process_continue_tv(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
record = await get_key_details(key_name, session)
|
||||
subscription_link = record["key"]
|
||||
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=TV_INSTRUCTIONS, url="https://vpn4tv.com/quick-guide.html"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_tv|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .key_connect import router as connect_router
|
||||
from .key_freeze import router as freeze_router
|
||||
from .key_mode import router as key_mode_router
|
||||
from .key_renew import router as renew_router
|
||||
from .key_view import router as view_router
|
||||
from .keys import router as keys_router
|
||||
|
||||
|
||||
router = Router(name="keys_main_router")
|
||||
|
||||
router.include_routers(keys_router, view_router, renew_router, freeze_router, connect_router, key_mode_router)
|
||||
@@ -0,0 +1,229 @@
|
||||
import os
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import qrcode
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, INSTRUCTIONS_BUTTON
|
||||
from handlers.buttons import (
|
||||
ANDROID,
|
||||
BACK,
|
||||
DOWNLOAD_ANDROID_BUTTON,
|
||||
DOWNLOAD_IOS_BUTTON,
|
||||
IMPORT_ANDROID,
|
||||
IMPORT_IOS,
|
||||
IPHONE,
|
||||
MAIN_MENU,
|
||||
MANUAL_INSTRUCTIONS,
|
||||
PC,
|
||||
TV,
|
||||
)
|
||||
from handlers.texts import (
|
||||
ANDROID_DESCRIPTION_TEMPLATE,
|
||||
CHOOSE_DEVICE_TEXT,
|
||||
IOS_DESCRIPTION_TEMPLATE,
|
||||
SUBSCRIPTION_DESCRIPTION,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_device|"))
|
||||
async def handle_connect_device(callback_query: CallbackQuery):
|
||||
try:
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
|
||||
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CHOOSE_DEVICE_TEXT,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
|
||||
logger.error(f"Ошибка в handle_connect_device: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_phone|"))
|
||||
async def process_callback_connect_phone(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_data = await conn.fetchrow(
|
||||
"""
|
||||
SELECT key FROM keys WHERE email = $1
|
||||
""",
|
||||
email,
|
||||
)
|
||||
if not key_data:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
|
||||
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
|
||||
)
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=description, reply_markup=builder.as_markup(), media_path=None
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_ios|"))
|
||||
async def process_callback_connect_ios(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
|
||||
if not key_data:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
|
||||
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=description,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_android|"))
|
||||
async def process_callback_connect_android(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
|
||||
if not key_data:
|
||||
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
|
||||
return
|
||||
|
||||
key_link = key_data["key"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
|
||||
return
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
|
||||
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
|
||||
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
|
||||
if INSTRUCTIONS_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=description,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("show_qr|"))
|
||||
async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
|
||||
try:
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
|
||||
if not record:
|
||||
await callback_query.message.answer("❌ Подписка не найдена.")
|
||||
return
|
||||
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(record["key"])
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
qr_path = f"/tmp/qrcode_{record['email']}.png"
|
||||
with open(qr_path, "wb") as f:
|
||||
f.write(buffer.read())
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="🔲 <b>Ваш QR-код для подключения</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=qr_path,
|
||||
)
|
||||
|
||||
os.remove(qr_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
|
||||
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
|
||||
@@ -0,0 +1,220 @@
|
||||
import time
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
TOTAL_GB,
|
||||
)
|
||||
from database import (
|
||||
get_key_details,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
APPLY,
|
||||
BACK,
|
||||
CANCEL,
|
||||
)
|
||||
from handlers.keys.key_utils import (
|
||||
renew_key_in_cluster,
|
||||
toggle_client_on_cluster,
|
||||
)
|
||||
from handlers.texts import (
|
||||
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
|
||||
SUBSCRIPTION_FROZEN_MSG,
|
||||
SUBSCRIPTION_UNFROZEN_MSG,
|
||||
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, handle_error
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
|
||||
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=APPLY,
|
||||
callback_data=f"unfreeze_subscription_confirm|{key_name}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=CANCEL,
|
||||
callback_data=f"view_key|{key_name}",
|
||||
),
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=confirm_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
|
||||
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
|
||||
"""
|
||||
Размораживает (включает) подписку.
|
||||
"""
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if not record:
|
||||
await callback_query.message.answer("Ключ не найден.")
|
||||
return
|
||||
|
||||
email = record["email"]
|
||||
client_id = record["client_id"]
|
||||
cluster_id = record["server_id"]
|
||||
|
||||
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True)
|
||||
if result["status"] == "success":
|
||||
now_ms = int(time.time() * 1000)
|
||||
leftover = record["expiry_time"]
|
||||
if leftover < 0:
|
||||
leftover = 0
|
||||
|
||||
new_expiry_time = now_ms + leftover
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET expiry_time = $1,
|
||||
is_frozen = FALSE
|
||||
WHERE tg_id = $2
|
||||
AND client_id = $3
|
||||
""",
|
||||
new_expiry_time,
|
||||
record["tg_id"],
|
||||
client_id,
|
||||
)
|
||||
added_days = max(leftover / (1000 * 86400), 0.01)
|
||||
total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
|
||||
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=cluster_id,
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=total_gb,
|
||||
)
|
||||
text_ok = SUBSCRIPTION_UNFROZEN_MSG
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_ok,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
text_error = (
|
||||
f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_error,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("freeze_subscription|"))
|
||||
async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
|
||||
"""
|
||||
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
|
||||
"""
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=APPLY,
|
||||
callback_data=f"freeze_subscription_confirm|{key_name}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=CANCEL,
|
||||
callback_data=f"view_key|{key_name}",
|
||||
),
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=confirm_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
|
||||
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
|
||||
"""
|
||||
Замораживает (отключает) подписку.
|
||||
"""
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if not record:
|
||||
await callback_query.message.answer("Ключ не найден.")
|
||||
return
|
||||
|
||||
email = record["email"]
|
||||
client_id = record["client_id"]
|
||||
cluster_id = record["server_id"]
|
||||
|
||||
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
|
||||
|
||||
if result["status"] == "success":
|
||||
now_ms = int(time.time() * 1000)
|
||||
time_left = record["expiry_time"] - now_ms
|
||||
if time_left < 0:
|
||||
time_left = 0
|
||||
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET expiry_time = $1,
|
||||
is_frozen = TRUE
|
||||
WHERE tg_id = $2
|
||||
AND client_id = $3
|
||||
""",
|
||||
time_left,
|
||||
record["tg_id"],
|
||||
client_id,
|
||||
)
|
||||
|
||||
text_ok = SUBSCRIPTION_FROZEN_MSG
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_ok,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
text_error = (
|
||||
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text_error,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
|
||||
@@ -1,293 +0,0 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
|
||||
from bot import bot, dp
|
||||
from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, PUBLIC_LINK
|
||||
from database import add_connection, get_balance, store_key, update_balance
|
||||
from handlers.instructions.instructions import send_instructions
|
||||
from handlers.keys.key_utils import create_key_on_cluster
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.texts import KEY, KEY_TRIAL, NULL_BALANCE, RENEWAL_PLANS, key_message_success
|
||||
from handlers.utils import get_least_loaded_cluster, sanitize_key_name
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class Form(StatesGroup):
|
||||
waiting_for_server_selection = State()
|
||||
waiting_for_key_name = State()
|
||||
viewing_profile = State()
|
||||
waiting_for_message = State()
|
||||
|
||||
|
||||
@dp.callback_query(F.data == "create_key")
|
||||
async def process_callback_create_key(callback_query: CallbackQuery, state: FSMContext):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
server_id = "все сервера"
|
||||
await state.update_data(selected_server_id=server_id)
|
||||
await select_server(callback_query, state)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
async def select_server(callback_query: CallbackQuery, state: FSMContext):
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
existing_connection = await conn.fetchrow(
|
||||
"SELECT trial FROM connections WHERE tg_id = $1",
|
||||
callback_query.from_user.id,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
trial_status = existing_connection["trial"] if existing_connection else 0
|
||||
|
||||
if trial_status == 1:
|
||||
await bot.send_message(
|
||||
chat_id=callback_query.from_user.id,
|
||||
text=KEY,
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да, подключить новое устройство",
|
||||
callback_data="confirm_create_new_key",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="↩️ Назад", callback_data="cancel_create_key"
|
||||
)
|
||||
],
|
||||
]
|
||||
),
|
||||
)
|
||||
await state.update_data(creating_new_key=True)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=callback_query.from_user.id, text=KEY_TRIAL, parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(Form.waiting_for_key_name)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@dp.callback_query(F.data == "confirm_create_new_key")
|
||||
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
logger.info(f"User {tg_id} confirmed creation of a new key.")
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
if balance < RENEWAL_PLANS["1"]["price"]:
|
||||
replenish_button = InlineKeyboardButton(
|
||||
text="Перейти в профиль", callback_data="view_profile"
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[replenish_button]])
|
||||
await callback_query.message.edit_text(NULL_BALANCE, reply_markup=keyboard)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
logger.info(f"Balance for user {tg_id} is sufficient. Asking for device name.")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
"🔑 Пожалуйста, введите имя подключаемого устройства:"
|
||||
)
|
||||
await state.set_state(Form.waiting_for_key_name)
|
||||
logger.info(f"State set to waiting_for_key_name for user {tg_id}")
|
||||
await state.update_data(creating_new_key=True)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@dp.callback_query(F.data == "cancel_create_key")
|
||||
async def cancel_create_key(callback_query: CallbackQuery, state: FSMContext):
|
||||
await process_callback_view_profile(callback_query, state)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.message(Form.waiting_for_key_name)
|
||||
async def handle_key_name_input(message: Message, state: FSMContext):
|
||||
tg_id = message.from_user.id
|
||||
key_name = sanitize_key_name(message.text)
|
||||
|
||||
logger.info(f"User {tg_id} is attempting to create a key with the name: {key_name}")
|
||||
|
||||
if not key_name:
|
||||
await message.bot.send_message(
|
||||
tg_id, "📝 Пожалуйста, назовите устройство на английском языке."
|
||||
)
|
||||
logger.warning(f"User {tg_id} entered an invalid key name: {key_name}")
|
||||
return
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
logger.info(
|
||||
f"Checking if key name '{key_name}' already exists in the database."
|
||||
)
|
||||
existing_key = await conn.fetchrow(
|
||||
"SELECT * FROM keys WHERE email = $1", key_name.lower()
|
||||
)
|
||||
if existing_key:
|
||||
await message.bot.send_message(
|
||||
tg_id,
|
||||
"❌ Упс! Это имя уже используется. Выберите другое уникальное название для ключа.",
|
||||
)
|
||||
logger.warning(
|
||||
f"Key name '{key_name}' already exists in the database for user {tg_id}."
|
||||
)
|
||||
await state.set_state(Form.waiting_for_key_name)
|
||||
return
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
client_id = str(uuid.uuid4())
|
||||
email = key_name.lower()
|
||||
current_time = datetime.utcnow()
|
||||
expiry_time = None
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
logger.info(f"Checking trial status for user {tg_id}.")
|
||||
existing_connection = await conn.fetchrow(
|
||||
"SELECT trial FROM connections WHERE tg_id = $1", tg_id
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
trial_status = existing_connection["trial"] if existing_connection else 0
|
||||
|
||||
if trial_status == 0:
|
||||
expiry_time = current_time + timedelta(days=1, hours=3)
|
||||
logger.info(f"Assigned 1-day trial to user {tg_id}.")
|
||||
else:
|
||||
balance = await get_balance(tg_id)
|
||||
if balance < RENEWAL_PLANS["1"]["price"]:
|
||||
replenish_button = InlineKeyboardButton(
|
||||
text="Перейти в профиль", callback_data="view_profile"
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[replenish_button]])
|
||||
await message.bot.send_message(
|
||||
tg_id,
|
||||
"💳 Недостаточно средств для создания подписки на новое устройство. Пополните баланс в личном кабинете.",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.warning(f"User {tg_id} has insufficient funds for key creation.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"])
|
||||
expiry_time = current_time + timedelta(days=30, hours=3)
|
||||
logger.info(f"User {tg_id} balance deducted for key creation.")
|
||||
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
public_link = f"{PUBLIC_LINK}{email}"
|
||||
|
||||
logger.info(f"Generated public link for the key: {public_link}")
|
||||
|
||||
button_profile = InlineKeyboardButton(
|
||||
text="👤 Личный кабинет", callback_data="view_profile"
|
||||
)
|
||||
button_iphone = InlineKeyboardButton(
|
||||
text="🍏 Подключить", url=f"{CONNECT_IOS}{public_link}"
|
||||
)
|
||||
button_android = InlineKeyboardButton(
|
||||
text="🤖 Подключить",
|
||||
url=f"{CONNECT_ANDROID}{public_link}",
|
||||
)
|
||||
|
||||
button_download_ios = InlineKeyboardButton(text="🍏 Скачать", url=DOWNLOAD_IOS)
|
||||
button_download_android = InlineKeyboardButton(
|
||||
text="🤖 Скачать",
|
||||
url=DOWNLOAD_ANDROID,
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[button_download_ios, button_download_android],
|
||||
[button_iphone, button_android],
|
||||
[button_profile],
|
||||
]
|
||||
)
|
||||
|
||||
remaining_time = expiry_time - current_time
|
||||
days = remaining_time.days
|
||||
key_message = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
logger.info(f"Sending key message to user {tg_id} with the public link.")
|
||||
|
||||
await message.bot.send_message(
|
||||
tg_id, key_message, parse_mode="HTML", reply_markup=keyboard
|
||||
)
|
||||
|
||||
try:
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
|
||||
tasks = []
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
create_key_on_cluster(
|
||||
least_loaded_cluster,
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
logger.info(f"Updating trial status for user {tg_id} in the database.")
|
||||
existing_connection = await conn.fetchrow(
|
||||
"SELECT * FROM connections WHERE tg_id = $1", tg_id
|
||||
)
|
||||
if existing_connection:
|
||||
await conn.execute(
|
||||
"UPDATE connections SET trial = 1 WHERE tg_id = $1", tg_id
|
||||
)
|
||||
else:
|
||||
await add_connection(tg_id, 0, 1)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
logger.info(f"Storing key for user {tg_id} in the database.")
|
||||
await store_key(
|
||||
tg_id, client_id, email, expiry_timestamp, public_link, least_loaded_cluster
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error while creating the key for user {tg_id}: {e}")
|
||||
await message.bot.send_message(tg_id, f"❌ Ошибка при создании ключа: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@dp.callback_query(F.data == "instructions")
|
||||
async def handle_instructions(callback_query: CallbackQuery):
|
||||
await send_instructions(callback_query)
|
||||
|
||||
|
||||
@dp.callback_query(F.data == "back_to_main")
|
||||
async def handle_back_to_main(callback_query: CallbackQuery, state: FSMContext):
|
||||
await process_callback_view_profile(callback_query, state)
|
||||
await callback_query.answer()
|
||||
@@ -0,0 +1,16 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .key_cluster_mode import router as cluster_router
|
||||
from .key_country_mode import router as country_router
|
||||
from .key_create import router as create_router
|
||||
|
||||
|
||||
router = Router(name="key_mode_router")
|
||||
|
||||
router.include_routers(
|
||||
create_router,
|
||||
cluster_router,
|
||||
country_router,
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
CONNECT_PHONE_BUTTON,
|
||||
RENEWAL_PRICES,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from database import (
|
||||
get_key_details,
|
||||
get_trial,
|
||||
update_balance,
|
||||
update_trial,
|
||||
)
|
||||
from handlers.buttons import CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
|
||||
from handlers.keys.key_utils import create_key_on_cluster
|
||||
from handlers.texts import (
|
||||
key_message_success,
|
||||
)
|
||||
from handlers.utils import (
|
||||
edit_or_send_message,
|
||||
generate_random_email,
|
||||
get_least_loaded_cluster,
|
||||
is_full_remnawave_cluster,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def key_cluster_mode(
|
||||
tg_id: int,
|
||||
expiry_time: datetime,
|
||||
state,
|
||||
session,
|
||||
message_or_query: Message | CallbackQuery | None = None,
|
||||
plan: int = None,
|
||||
):
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
existing_key = await get_key_details(key_name, session)
|
||||
if not existing_key:
|
||||
break
|
||||
|
||||
client_id = str(uuid.uuid4())
|
||||
email = key_name.lower()
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
|
||||
try:
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
await create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan, session)
|
||||
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
|
||||
|
||||
key_record = await get_key_details(email, session)
|
||||
if not key_record:
|
||||
raise ValueError(f"Ключ не найден после создания: {email}")
|
||||
|
||||
public_link = key_record.get("key")
|
||||
remnawave_link = key_record.get("remnawave_link")
|
||||
final_link = public_link or remnawave_link or ""
|
||||
|
||||
data = await state.get_data() if state else {}
|
||||
|
||||
if data.get("is_trial"):
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
if trial_status in [0, -1]:
|
||||
await update_trial(tg_id, 1, session)
|
||||
|
||||
if data.get("plan_id"):
|
||||
plan_price = RENEWAL_PRICES.get(data["plan_id"])
|
||||
await update_balance(tg_id, -plan_price, session)
|
||||
|
||||
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
|
||||
if target_message:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=error_message, reply_markup=None, media_path=None
|
||||
)
|
||||
else:
|
||||
await bot.send_message(chat_id=tg_id, text=error_message)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if await is_full_remnawave_cluster(least_loaded_cluster, session):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
web_app=WebAppInfo(url=final_link),
|
||||
)
|
||||
)
|
||||
elif CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
callback_data=f"connect_device|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
expiry_time_local = expiry_time.astimezone(moscow_tz)
|
||||
remaining_time = expiry_time_local - datetime.now(moscow_tz)
|
||||
days = remaining_time.days
|
||||
key_message_text = key_message_success(final_link, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
default_media_path = "img/pic.jpg"
|
||||
|
||||
if target_message:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=key_message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=default_media_path,
|
||||
)
|
||||
else:
|
||||
photo = FSInputFile(default_media_path)
|
||||
await bot.send_photo(
|
||||
chat_id=tg_id,
|
||||
photo=photo,
|
||||
caption=key_message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
if state:
|
||||
await state.clear()
|
||||
@@ -0,0 +1,511 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import asyncpg
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
CONNECT_PHONE_BUTTON,
|
||||
PUBLIC_LINK,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
RENEWAL_PRICES,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from database import (
|
||||
add_user,
|
||||
check_server_name_by_cluster,
|
||||
check_user_exists,
|
||||
get_key_details,
|
||||
get_trial,
|
||||
update_balance,
|
||||
update_trial,
|
||||
)
|
||||
from handlers.buttons import BACK, CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
|
||||
from handlers.keys.key_utils import create_client_on_server
|
||||
from handlers.texts import (
|
||||
SELECT_COUNTRY_MSG,
|
||||
key_message_success,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import delete_client, get_xui_instance
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def key_country_mode(
|
||||
tg_id: int,
|
||||
expiry_time: datetime,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
message_or_query: Message | CallbackQuery | None = None,
|
||||
old_key_name: str = None,
|
||||
):
|
||||
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
|
||||
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
servers = await session.fetch(
|
||||
"SELECT server_name, api_url, panel_type FROM servers WHERE cluster_name = $1",
|
||||
least_loaded_cluster,
|
||||
)
|
||||
|
||||
if not servers:
|
||||
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
|
||||
error_message = "❌ Нет доступных серверов для создания ключа."
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=error_message,
|
||||
reply_markup=None,
|
||||
)
|
||||
return
|
||||
|
||||
available_servers = []
|
||||
tasks = [asyncio.create_task(check_server_availability(server)) for server in servers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for server, result in zip(servers, results, strict=False):
|
||||
if result is True:
|
||||
available_servers.append(server["server_name"])
|
||||
|
||||
if not available_servers:
|
||||
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
|
||||
error_message = "❌ Нет доступных серверов для создания ключа."
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=error_message,
|
||||
reply_markup=None,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"[Country Selection] Доступные серверы для выбора: {available_servers}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
ts = int(expiry_time.timestamp())
|
||||
|
||||
for country in available_servers:
|
||||
if old_key_name:
|
||||
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
||||
else:
|
||||
callback_data = f"select_country|{country}|{ts}"
|
||||
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=SELECT_COUNTRY_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("change_location|"))
|
||||
async def change_location_callback(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
data = callback_query.data.split("|")
|
||||
if len(data) < 2:
|
||||
await callback_query.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
old_key_name = data[1]
|
||||
record = await get_key_details(old_key_name, session)
|
||||
if not record:
|
||||
await callback_query.answer("❌ Ключ не найден", show_alert=True)
|
||||
return
|
||||
|
||||
expiry_timestamp = record["expiry_time"]
|
||||
ts = int(expiry_timestamp / 1000)
|
||||
|
||||
current_server = record["server_id"]
|
||||
|
||||
cluster_info = await check_server_name_by_cluster(current_server, session)
|
||||
if not cluster_info:
|
||||
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
|
||||
return
|
||||
|
||||
cluster_name = cluster_info["cluster_name"]
|
||||
|
||||
servers = await session.fetch(
|
||||
"SELECT server_name, api_url, panel_type, enabled, max_keys FROM servers WHERE cluster_name = $1 AND server_name != $2",
|
||||
cluster_name,
|
||||
current_server,
|
||||
)
|
||||
if not servers:
|
||||
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
available_servers = []
|
||||
tasks = []
|
||||
|
||||
for server in servers:
|
||||
server_info = {
|
||||
"server_name": server["server_name"],
|
||||
"api_url": server["api_url"],
|
||||
"panel_type": server["panel_type"],
|
||||
"enabled": server.get("enabled", True),
|
||||
"max_keys": server.get("max_keys"),
|
||||
}
|
||||
task = asyncio.create_task(check_server_availability(server_info))
|
||||
tasks.append(task)
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for server, result in zip(servers, results, strict=False):
|
||||
if result is True:
|
||||
available_servers.append(server["server_name"])
|
||||
|
||||
if not available_servers:
|
||||
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
|
||||
return
|
||||
|
||||
logger.info(f"Доступные страны для смены локации: {available_servers}")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for country in available_servers:
|
||||
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
|
||||
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
|
||||
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_country|"))
|
||||
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
"""
|
||||
Обрабатывает выбор страны.
|
||||
Формат callback data:
|
||||
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
|
||||
Если передан old_key_name – значит, происходит смена локации.
|
||||
"""
|
||||
data = callback_query.data.split("|")
|
||||
if len(data) < 3:
|
||||
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
|
||||
return
|
||||
|
||||
selected_country = data[1]
|
||||
try:
|
||||
ts = int(data[2])
|
||||
except ValueError:
|
||||
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
|
||||
return
|
||||
|
||||
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
|
||||
|
||||
old_key_name = data[3] if len(data) > 3 else None
|
||||
|
||||
tg_id = callback_query.from_user.id
|
||||
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
|
||||
logger.info(f"Получено время истечения (timestamp): {ts}")
|
||||
|
||||
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
|
||||
|
||||
|
||||
async def finalize_key_creation(
|
||||
tg_id: int,
|
||||
expiry_time: datetime,
|
||||
selected_country: str,
|
||||
state: FSMContext | None,
|
||||
session: Any,
|
||||
callback_query: CallbackQuery,
|
||||
old_key_name: str = None,
|
||||
):
|
||||
if not await check_user_exists(tg_id):
|
||||
if isinstance(callback_query, CallbackQuery):
|
||||
from_user = callback_query.from_user
|
||||
else:
|
||||
from_user = callback_query.from_user
|
||||
|
||||
await add_user(
|
||||
tg_id=from_user.id,
|
||||
username=from_user.username,
|
||||
first_name=from_user.first_name,
|
||||
last_name=from_user.last_name,
|
||||
language_code=from_user.language_code,
|
||||
is_bot=from_user.is_bot,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
|
||||
|
||||
expiry_time = expiry_time.astimezone(moscow_tz)
|
||||
|
||||
if old_key_name:
|
||||
old_key_details = await get_key_details(old_key_name, session)
|
||||
if not old_key_details:
|
||||
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
|
||||
return
|
||||
|
||||
key_name = old_key_name
|
||||
client_id = old_key_details["client_id"]
|
||||
email = old_key_details["email"]
|
||||
expiry_timestamp = old_key_details["expiry_time"]
|
||||
else:
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
existing_key = await get_key_details(key_name, session)
|
||||
if not existing_key:
|
||||
break
|
||||
client_id = str(uuid.uuid4())
|
||||
email = key_name.lower()
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
|
||||
remna = None
|
||||
|
||||
try:
|
||||
server_info = await session.fetchrow(
|
||||
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
|
||||
selected_country,
|
||||
)
|
||||
if not server_info:
|
||||
raise ValueError(f"Сервер {selected_country} не найден.")
|
||||
|
||||
panel_type = server_info["panel_type"].lower()
|
||||
|
||||
public_link = None
|
||||
remnawave_link = None
|
||||
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
|
||||
if old_key_name:
|
||||
old_server_id = old_key_details.get("server_id")
|
||||
if old_server_id:
|
||||
old_server_info = await session.fetchrow(
|
||||
"SELECT api_url, inbound_id, server_name, panel_type FROM servers WHERE server_name = $1",
|
||||
old_server_id,
|
||||
)
|
||||
if old_server_info:
|
||||
old_panel_type = old_server_info["panel_type"].lower()
|
||||
try:
|
||||
if old_panel_type == "3x-ui":
|
||||
xui = await get_xui_instance(old_server_info["api_url"])
|
||||
|
||||
await delete_client(
|
||||
xui,
|
||||
old_server_info["inbound_id"],
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
await session.execute(
|
||||
"UPDATE keys SET key = NULL WHERE tg_id = $1 AND email = $2",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
logger.info(f"[Delete] Удалён клиент {email} с 3x-ui сервера {old_server_id}")
|
||||
elif old_panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(old_server_info["api_url"])
|
||||
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
await remna.delete_user(client_id)
|
||||
await session.execute(
|
||||
"UPDATE keys SET remnawave_link = NULL WHERE tg_id = $1 AND email = $2",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
logger.info(f"[Delete] Удалён клиент {client_id} с Remnawave сервера {old_server_id}")
|
||||
else:
|
||||
logger.warning(f"[Delete] Не удалось авторизоваться в Remnawave ({old_server_id})")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Delete] Ошибка при удалении клиента с сервера {old_server_id}: {e}")
|
||||
|
||||
if panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(server_info["api_url"])
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({selected_country})")
|
||||
|
||||
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||
user_data = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_at,
|
||||
"telegramId": tg_id,
|
||||
"activeUserInbounds": [server_info["inbound_id"]],
|
||||
}
|
||||
result = await remna.create_user(user_data)
|
||||
if not result:
|
||||
raise ValueError("❌ Ошибка при создании пользователя в Remnawave")
|
||||
|
||||
client_id = result.get("uuid")
|
||||
remnawave_link = result.get("subscriptionUrl")
|
||||
logger.info(f"[Key Creation] Remnawave пользователь создан: {result}")
|
||||
|
||||
if old_key_name:
|
||||
await session.execute(
|
||||
"UPDATE keys SET client_id = $1 WHERE tg_id = $2 AND email = $3",
|
||||
client_id,
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
|
||||
if panel_type == "3x-ui":
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
await create_client_on_server(
|
||||
server_info=server_info,
|
||||
tg_id=tg_id,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
expiry_timestamp=expiry_timestamp,
|
||||
semaphore=semaphore,
|
||||
)
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
logger.info(f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}")
|
||||
|
||||
if old_key_name:
|
||||
await session.execute(
|
||||
"UPDATE keys SET server_id = $1 WHERE tg_id = $2 AND email = $3",
|
||||
selected_country,
|
||||
tg_id,
|
||||
old_key_name,
|
||||
)
|
||||
if panel_type == "3x-ui":
|
||||
await session.execute(
|
||||
"UPDATE keys SET key = $1 WHERE tg_id = $2 AND email = $3",
|
||||
public_link,
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
elif panel_type == "remnawave":
|
||||
await session.execute(
|
||||
"UPDATE keys SET remnawave_link = $1 WHERE tg_id = $2 AND email = $3",
|
||||
remnawave_link,
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
|
||||
else:
|
||||
await session.execute(
|
||||
"""
|
||||
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, remnawave_link, server_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
""",
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
created_at,
|
||||
expiry_timestamp,
|
||||
public_link,
|
||||
remnawave_link,
|
||||
selected_country,
|
||||
)
|
||||
data = await state.get_data()
|
||||
if data.get("is_trial"):
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
if trial_status in [0, -1]:
|
||||
await update_trial(tg_id, 1, session)
|
||||
if data.get("plan_id"):
|
||||
plan_price = RENEWAL_PRICES.get(data["plan_id"])
|
||||
await update_balance(tg_id, -plan_price, session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Попробуйте снова.")
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
|
||||
)
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
remaining_time = expiry_time - datetime.now(moscow_tz)
|
||||
days = remaining_time.days
|
||||
|
||||
link_to_show = public_link or remnawave_link or "Ссылка не найдена"
|
||||
key_message_text = key_message_success(link_to_show, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=key_message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path="img/pic.jpg",
|
||||
)
|
||||
|
||||
if state:
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def check_server_availability(server_info: dict, session: Any = None) -> bool:
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
enabled = server_info.get("enabled", True)
|
||||
max_keys = server_info.get("max_keys")
|
||||
|
||||
if not enabled:
|
||||
logger.info(f"[Ping] Сервер {server_name} выключен (enabled = FALSE).")
|
||||
return False
|
||||
|
||||
connection = None
|
||||
external_session = session is not None
|
||||
|
||||
try:
|
||||
if not external_session:
|
||||
connection = await asyncpg.connect(DATABASE_URL)
|
||||
session = connection
|
||||
|
||||
if max_keys is not None:
|
||||
count_query = "SELECT COUNT(*) FROM keys WHERE server_id = $1"
|
||||
key_count = await session.fetchval(count_query, server_name)
|
||||
if key_count >= max_keys:
|
||||
logger.info(f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}.")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}")
|
||||
return False
|
||||
finally:
|
||||
if connection:
|
||||
await connection.close()
|
||||
|
||||
try:
|
||||
if panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(server_info["api_url"])
|
||||
await asyncio.wait_for(remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0)
|
||||
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
|
||||
return True
|
||||
|
||||
else:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
logger=logger,
|
||||
)
|
||||
await asyncio.wait_for(xui.login(), timeout=5.0)
|
||||
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
|
||||
return True
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"[Ping] Ошибка при проверке сервера {server_name}: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,213 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
RENEWAL_PRICES,
|
||||
TRIAL_TIME,
|
||||
TRIAL_TIME_DISABLE,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from database import (
|
||||
add_user,
|
||||
check_user_exists,
|
||||
create_temporary_data,
|
||||
get_balance,
|
||||
get_trial,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
)
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||
from handlers.texts import (
|
||||
CREATING_CONNECTION_MSG,
|
||||
DISCOUNTS,
|
||||
INSUFFICIENT_FUNDS_MSG,
|
||||
SELECT_TARIFF_PLAN_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
from .key_cluster_mode import key_cluster_mode
|
||||
from .key_country_mode import key_country_mode
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
class Form(FSMContext):
|
||||
waiting_for_server_selection = "waiting_for_server_selection"
|
||||
|
||||
|
||||
@router.callback_query(F.data == "create_key")
|
||||
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
await handle_key_creation(tg_id, state, session, callback_query)
|
||||
|
||||
|
||||
async def handle_key_creation(
|
||||
tg_id: int,
|
||||
state: FSMContext,
|
||||
session: Any,
|
||||
message_or_query: Message | CallbackQuery,
|
||||
):
|
||||
"""Создание ключа с учётом выбора тарифного плана."""
|
||||
current_time = datetime.now(moscow_tz)
|
||||
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
if trial_status in [0, -1]:
|
||||
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
|
||||
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
|
||||
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
|
||||
await edit_or_send_message(
|
||||
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=None,
|
||||
)
|
||||
await state.update_data(is_trial=True)
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
|
||||
discount_text = ""
|
||||
if DISCOUNTS and plan_id in DISCOUNTS:
|
||||
discount_percentage = DISCOUNTS[plan_id]
|
||||
discount_text = f" ({discount_percentage}% скидка)"
|
||||
if index == len(RENEWAL_PRICES) - 1:
|
||||
discount_text = f" ({discount_percentage}% 🔥)"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {plan_id} мес. - {price}₽{discount_text}",
|
||||
callback_data=f"select_plan_{plan_id}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
if isinstance(message_or_query, CallbackQuery):
|
||||
target_message = message_or_query.message
|
||||
else:
|
||||
target_message = message_or_query
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=SELECT_TARIFF_PLAN_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
|
||||
await state.update_data(tg_id=tg_id)
|
||||
await state.set_state(Form.waiting_for_server_selection)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("select_plan_"))
|
||||
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
|
||||
tg_id = callback_query.message.chat.id
|
||||
plan_id = callback_query.data.split("_")[-1]
|
||||
plan_price = RENEWAL_PRICES.get(plan_id)
|
||||
if plan_price is None:
|
||||
await callback_query.message.answer("🚫 Неверный тарифный план.")
|
||||
return
|
||||
duration_days = int(plan_id) * 30
|
||||
balance = await get_balance(tg_id)
|
||||
if balance < plan_price:
|
||||
required_amount = plan_price - balance
|
||||
await create_temporary_data(
|
||||
session,
|
||||
tg_id,
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"plan_price": plan_price,
|
||||
"duration_days": duration_days,
|
||||
"required_amount": required_amount,
|
||||
},
|
||||
)
|
||||
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
else:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
return
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=CREATING_CONNECTION_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
|
||||
await state.update_data(plan_id=plan_id)
|
||||
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
|
||||
|
||||
|
||||
async def create_key(
|
||||
tg_id: int,
|
||||
expiry_time,
|
||||
state,
|
||||
session,
|
||||
message_or_query=None,
|
||||
old_key_name: str = None,
|
||||
plan: int = None,
|
||||
):
|
||||
"""
|
||||
Универсальная точка входа для создания ключа.
|
||||
Делегирует выполнение в зависимости от выбранного режима (страна или кластер).
|
||||
Также отвечает за первичное подключение пользователя.
|
||||
"""
|
||||
if not await check_user_exists(tg_id):
|
||||
from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None
|
||||
if from_user:
|
||||
await add_user(
|
||||
tg_id=from_user.id,
|
||||
username=from_user.username,
|
||||
first_name=from_user.first_name,
|
||||
last_name=from_user.last_name,
|
||||
language_code=from_user.language_code,
|
||||
is_bot=from_user.is_bot,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
await key_country_mode(
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry_time,
|
||||
state=state,
|
||||
session=session,
|
||||
message_or_query=message_or_query,
|
||||
old_key_name=old_key_name,
|
||||
)
|
||||
else:
|
||||
await key_cluster_mode(
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry_time,
|
||||
state=state,
|
||||
session=session,
|
||||
message_or_query=message_or_query,
|
||||
plan=plan,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
RENEWAL_PRICES,
|
||||
TOTAL_GB,
|
||||
USE_COUNTRY_SELECTION,
|
||||
USE_NEW_PAYMENT_FLOW,
|
||||
)
|
||||
from database import (
|
||||
check_server_name_by_cluster,
|
||||
create_temporary_data,
|
||||
get_balance,
|
||||
get_key_by_server,
|
||||
get_key_details,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
BACK,
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
)
|
||||
from handlers.keys.key_utils import (
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from handlers.payments.robokassa_pay import handle_custom_amount_input
|
||||
from handlers.payments.yookassa_pay import process_custom_amount_input
|
||||
from handlers.texts import (
|
||||
DISCOUNTS,
|
||||
INSUFFICIENT_FUNDS_RENEWAL_MSG,
|
||||
KEY_NOT_FOUND_MSG,
|
||||
PLAN_SELECTION_MSG,
|
||||
SUCCESS_RENEWAL_MSG,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, format_months
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_key|"))
|
||||
async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if record:
|
||||
client_id = record["client_id"]
|
||||
expiry_time = record["expiry_time"]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for plan_id, price in RENEWAL_PRICES.items():
|
||||
months = int(plan_id)
|
||||
|
||||
discount = DISCOUNTS.get(plan_id, 0) if isinstance(DISCOUNTS, dict) else 0
|
||||
|
||||
button_text = f"📅 {format_months(months)} ({price} руб.)"
|
||||
if discount > 0:
|
||||
button_text += f" {discount}% скидка"
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"renew_plan|{months}|{client_id}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
response_message = PLAN_SELECTION_MSG.format(
|
||||
balance=balance,
|
||||
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer("<b>Ключ не найден.</b>")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в process_callback_renew_key: {e}")
|
||||
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
|
||||
days_to_extend = 30 * int(plan)
|
||||
|
||||
total_gb = int((int(plan) or 1) * TOTAL_GB * 1024**3)
|
||||
|
||||
try:
|
||||
record = await get_key_by_server(tg_id, client_id, session)
|
||||
|
||||
if record:
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
current_time = datetime.utcnow().timestamp() * 1000
|
||||
|
||||
if expiry_time <= current_time:
|
||||
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
|
||||
else:
|
||||
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
|
||||
|
||||
cost = RENEWAL_PRICES.get(plan)
|
||||
if cost is None:
|
||||
await callback_query.message.answer("❌ Неверный тарифный план.")
|
||||
return
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
if balance < cost:
|
||||
required_amount = cost - balance
|
||||
|
||||
logger.info(
|
||||
f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
|
||||
)
|
||||
|
||||
await create_temporary_data(
|
||||
session,
|
||||
tg_id,
|
||||
"waiting_for_renewal_payment",
|
||||
{
|
||||
"plan": plan,
|
||||
"client_id": client_id,
|
||||
"cost": cost,
|
||||
"required_amount": required_amount,
|
||||
"new_expiry_time": new_expiry_time,
|
||||
"total_gb": total_gb,
|
||||
"email": email,
|
||||
},
|
||||
)
|
||||
|
||||
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
|
||||
logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
|
||||
await process_custom_amount_input(callback_query, session)
|
||||
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
|
||||
logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
|
||||
await handle_custom_amount_input(callback_query, session)
|
||||
else:
|
||||
logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
|
||||
await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
|
||||
|
||||
else:
|
||||
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
|
||||
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
|
||||
except Exception as e:
|
||||
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
|
||||
|
||||
|
||||
async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
|
||||
logger.info(f"[Info] Продление ключа {client_id} на {plan} мес. (Start)")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
response_message = SUCCESS_RENEWAL_MSG.format(months_formatted=format_months(int(plan)))
|
||||
|
||||
if callback_query:
|
||||
try:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
|
||||
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
|
||||
else:
|
||||
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
key_info = await get_key_details(email, conn)
|
||||
if not key_info:
|
||||
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
|
||||
await conn.close()
|
||||
return
|
||||
|
||||
server_id = key_info["server_id"]
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
cluster_info = await check_server_name_by_cluster(server_id, conn)
|
||||
if not cluster_info:
|
||||
logger.error(f"[Error] Сервер {server_id} не найден в таблице servers.")
|
||||
await conn.close()
|
||||
return
|
||||
cluster_id = cluster_info["cluster_name"]
|
||||
else:
|
||||
cluster_id = server_id
|
||||
|
||||
await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
|
||||
await update_key_expiry(client_id, new_expiry_time, conn)
|
||||
await update_balance(tg_id, -cost, conn)
|
||||
await conn.close()
|
||||
|
||||
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
|
||||
+731
-104
@@ -1,165 +1,792 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from client import add_client, delete_client, extend_client_key
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, CLUSTERS, DATABASE_URL, TOTAL_GB
|
||||
from bot import bot
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
LIMIT_IP,
|
||||
PUBLIC_LINK,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
SUPERNODE,
|
||||
TOTAL_GB,
|
||||
)
|
||||
from database import delete_notification, get_servers, store_key
|
||||
from handlers.utils import check_server_key_limit, get_least_loaded_cluster
|
||||
from logger import logger
|
||||
from panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import (
|
||||
ClientConfig,
|
||||
add_client,
|
||||
delete_client,
|
||||
extend_client_key,
|
||||
get_client_traffic,
|
||||
get_xui_instance,
|
||||
toggle_client,
|
||||
)
|
||||
|
||||
|
||||
async def create_key_on_cluster(cluster_id, tg_id, client_id, email, expiry_timestamp):
|
||||
async def create_key_on_cluster(
|
||||
cluster_id: str,
|
||||
tg_id: int,
|
||||
client_id: str,
|
||||
email: str,
|
||||
expiry_timestamp: int,
|
||||
plan: int = None,
|
||||
session=None,
|
||||
remnawave_link: str = None,
|
||||
):
|
||||
try:
|
||||
tasks = []
|
||||
cluster = CLUSTERS.get(cluster_id)
|
||||
servers = await get_servers(include_enabled=True)
|
||||
cluster = servers.get(cluster_id)
|
||||
server_id_to_store = cluster_id
|
||||
|
||||
if not cluster:
|
||||
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
|
||||
found_servers = []
|
||||
for _key, server_list in servers.items():
|
||||
for server_info in server_list:
|
||||
if server_info.get("server_name", "").lower() == cluster_id.lower():
|
||||
found_servers.append(server_info)
|
||||
if found_servers:
|
||||
cluster = found_servers
|
||||
server_id_to_store = found_servers[0].get("server_name")
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
for server_info in cluster.values():
|
||||
xui = AsyncApi(
|
||||
server_info["API_URL"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
enabled_servers = [s for s in cluster if s.get("enabled", True)]
|
||||
if not enabled_servers:
|
||||
logger.warning(f"[Key Creation] Нет доступных серверов в кластере {cluster_id}")
|
||||
return
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
existing_key = await conn.fetchrow(
|
||||
"SELECT 1 FROM keys WHERE email = $1", email
|
||||
)
|
||||
async with asyncpg.create_pool(DATABASE_URL) as pool:
|
||||
async with pool.acquire() as conn:
|
||||
remnawave_servers = [
|
||||
s
|
||||
for s in enabled_servers
|
||||
if s.get("panel_type", "3x-ui").lower() == "remnawave" and await check_server_key_limit(s, conn)
|
||||
]
|
||||
xui_servers = [
|
||||
s
|
||||
for s in enabled_servers
|
||||
if s.get("panel_type", "3x-ui").lower() == "3x-ui" and await check_server_key_limit(s, conn)
|
||||
]
|
||||
|
||||
if existing_key:
|
||||
raise ValueError(f"Email {email} уже существует в базе данных.")
|
||||
if not remnawave_servers and not xui_servers:
|
||||
logger.warning(f"[Key Creation] Нет серверов с доступным лимитом в кластере {cluster_id}")
|
||||
return
|
||||
|
||||
tasks.append(
|
||||
add_client(
|
||||
xui,
|
||||
client_id,
|
||||
email,
|
||||
tg_id,
|
||||
limit_ip=1,
|
||||
total_gb=TOTAL_GB,
|
||||
expiry_time=expiry_timestamp,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
semaphore = asyncio.Semaphore(2)
|
||||
|
||||
remnawave_created = False
|
||||
remnawave_key = None
|
||||
remnawave_client_id = None
|
||||
|
||||
if remnawave_servers:
|
||||
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
|
||||
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||
if not logged_in:
|
||||
logger.error("Не удалось войти в Remnawave API")
|
||||
else:
|
||||
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||
inbound_ids = [s.get("inbound_id") for s in remnawave_servers if s.get("inbound_id")]
|
||||
|
||||
if not inbound_ids:
|
||||
logger.warning("Нет inbound_id у серверов Remnawave")
|
||||
else:
|
||||
traffic_limit_bytes = int((plan or 1) * TOTAL_GB * 1024**3)
|
||||
short_uuid = None
|
||||
if remnawave_link and "/" in remnawave_link:
|
||||
short_uuid = remnawave_link.rstrip("/").split("/")[-1]
|
||||
|
||||
user_data = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"trafficLimitBytes": traffic_limit_bytes,
|
||||
"expireAt": expire_at,
|
||||
"telegramId": tg_id,
|
||||
"activeUserInbounds": inbound_ids,
|
||||
}
|
||||
|
||||
if short_uuid:
|
||||
user_data["shortUuid"] = short_uuid
|
||||
|
||||
result = await remna.create_user(user_data)
|
||||
if not result:
|
||||
logger.error("Ошибка при создании пользователя в Remnawave")
|
||||
else:
|
||||
remnawave_created = True
|
||||
remnawave_key = result.get("subscriptionUrl")
|
||||
remnawave_client_id = result.get("uuid")
|
||||
logger.info(f"[Key Creation] Пользователь создан в Remnawave: {result}")
|
||||
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}" if xui_servers else None
|
||||
final_client_id = remnawave_client_id or client_id
|
||||
|
||||
if xui_servers:
|
||||
if SUPERNODE:
|
||||
for server_info in xui_servers:
|
||||
await create_client_on_server(
|
||||
server_info,
|
||||
tg_id,
|
||||
final_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
semaphore,
|
||||
plan=plan,
|
||||
)
|
||||
else:
|
||||
await asyncio.gather(
|
||||
*(
|
||||
create_client_on_server(
|
||||
server,
|
||||
tg_id,
|
||||
final_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
semaphore,
|
||||
plan=plan,
|
||||
)
|
||||
for server in xui_servers
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
if (remnawave_created and remnawave_client_id) or xui_servers:
|
||||
await store_key(
|
||||
tg_id,
|
||||
final_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
key=public_link,
|
||||
server_id=server_id_to_store,
|
||||
session=session,
|
||||
remnawave_link=remnawave_key,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании ключа: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def create_client_on_server(
|
||||
server_info: dict,
|
||||
tg_id: int,
|
||||
client_id: str,
|
||||
email: str,
|
||||
expiry_timestamp: int,
|
||||
semaphore: asyncio.Semaphore,
|
||||
plan: int = None,
|
||||
):
|
||||
"""
|
||||
Создает клиента на указанном сервере.
|
||||
"""
|
||||
async with semaphore:
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
|
||||
if not inbound_id:
|
||||
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
|
||||
return
|
||||
|
||||
if SUPERNODE:
|
||||
unique_email = f"{email}_{server_name.lower()}"
|
||||
sub_id = email
|
||||
else:
|
||||
unique_email = email
|
||||
sub_id = unique_email
|
||||
|
||||
total_gb_value = int((plan or 1) * TOTAL_GB * 1024**3)
|
||||
|
||||
await add_client(
|
||||
xui,
|
||||
ClientConfig(
|
||||
client_id=client_id,
|
||||
email=unique_email,
|
||||
tg_id=tg_id,
|
||||
limit_ip=LIMIT_IP,
|
||||
total_gb=total_gb_value,
|
||||
expiry_time=expiry_timestamp,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
inbound_id=int(inbound_id),
|
||||
sub_id=sub_id,
|
||||
),
|
||||
)
|
||||
|
||||
if SUPERNODE:
|
||||
await asyncio.sleep(0.7)
|
||||
|
||||
|
||||
async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb):
|
||||
"""
|
||||
Функция для продления срока действия ключа на всех серверах в кластере
|
||||
и обновления объема трафика.
|
||||
"""
|
||||
try:
|
||||
cluster = CLUSTERS.get(cluster_id)
|
||||
servers = await get_servers()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
|
||||
found_servers = []
|
||||
for _key, server_list in servers.items():
|
||||
for server_info in server_list:
|
||||
if server_info.get("server_name", "").lower() == cluster_id.lower():
|
||||
found_servers.append(server_info)
|
||||
if found_servers:
|
||||
cluster = found_servers
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
async with asyncpg.create_pool(DATABASE_URL) as pool:
|
||||
async with pool.acquire() as conn:
|
||||
tg_id_query = "SELECT tg_id FROM keys WHERE client_id = $1 LIMIT 1"
|
||||
tg_id_record = await conn.fetchrow(tg_id_query, client_id)
|
||||
|
||||
if not tg_id_record:
|
||||
logger.error(f"Не найден пользователь с client_id={client_id} в таблице keys.")
|
||||
return False
|
||||
|
||||
tg_id = tg_id_record["tg_id"]
|
||||
|
||||
notification_prefixes = ["key_24h", "key_10h", "key_expired", "renew"]
|
||||
for notif in notification_prefixes:
|
||||
notification_id = f"{email}_{notif}"
|
||||
await delete_notification(tg_id, notification_id, session=conn)
|
||||
logger.info(f"🧹 Уведомления для ключа {email} очищены при продлении.")
|
||||
|
||||
remnawave_inbound_ids = []
|
||||
tasks = []
|
||||
for server_info in cluster.values():
|
||||
xui = AsyncApi(
|
||||
server_info["API_URL"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
for server_info in cluster:
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
|
||||
if panel_type == "remnawave":
|
||||
inbound_uuid = server_info.get("inbound_id")
|
||||
if inbound_uuid:
|
||||
remnawave_inbound_ids.append(inbound_uuid)
|
||||
else:
|
||||
logger.warning(f"Не указан inbound_id для продления Remnawave на сервере {server_name}")
|
||||
|
||||
if remnawave_inbound_ids:
|
||||
remnawave_server = next(
|
||||
(
|
||||
srv
|
||||
for srv in cluster
|
||||
if srv.get("panel_type", "").lower() == "remnawave"
|
||||
and srv.get("inbound_id") in remnawave_inbound_ids
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
tasks.append(
|
||||
extend_client_key(xui, email, new_expiry_time, client_id, total_gb)
|
||||
)
|
||||
if not remnawave_server:
|
||||
logger.error("❌ Не найден Remnawave сервер для продления")
|
||||
else:
|
||||
remna = RemnawaveAPI(remnawave_server["api_url"])
|
||||
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||
if logged_in:
|
||||
expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z"
|
||||
updated = await remna.update_user(
|
||||
uuid=client_id,
|
||||
expire_at=expire_iso,
|
||||
active_user_inbounds=remnawave_inbound_ids,
|
||||
traffic_limit_bytes=total_gb,
|
||||
)
|
||||
if updated:
|
||||
logger.info(f"Подписка Remnawave {client_id} успешно продлена")
|
||||
await remna.reset_user_traffic(client_id)
|
||||
else:
|
||||
logger.warning(f"Не удалось продлить подписку Remnawave {client_id}")
|
||||
else:
|
||||
logger.error("Не удалось войти в Remnawave API")
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
for server_info in cluster:
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
|
||||
if panel_type == "3x-ui":
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
|
||||
if not inbound_id:
|
||||
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
|
||||
continue
|
||||
|
||||
if SUPERNODE:
|
||||
unique_email = f"{email}_{server_name.lower()}"
|
||||
sub_id = email
|
||||
else:
|
||||
unique_email = email
|
||||
sub_id = unique_email
|
||||
|
||||
tasks.append(
|
||||
extend_client_key(
|
||||
xui, int(inbound_id), unique_email, new_expiry_time, client_id, total_gb, sub_id, tg_id
|
||||
)
|
||||
)
|
||||
|
||||
elif panel_type != "remnawave":
|
||||
logger.warning(f"Неизвестный тип панели '{panel_type}' для сервера {server_name}")
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Не удалось продлить ключ {client_id} в кластере {cluster_id}: {e}"
|
||||
)
|
||||
logger.error(f"Не удалось продлить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def delete_key_from_db(client_id):
|
||||
"""Удаление ключа из базы данных"""
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
await conn.execute("DELETE FROM keys WHERE client_id = $1", client_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {client_id} из базы данных: {e}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def delete_key_from_cluster(cluster_id, email, client_id):
|
||||
"""Удаление ключа с серверов в кластере"""
|
||||
"""Удаление ключа с серверов в кластере или с конкретного сервера"""
|
||||
try:
|
||||
cluster = CLUSTERS.get(cluster_id)
|
||||
servers = await get_servers()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
|
||||
found_servers = []
|
||||
for _, server_list in servers.items():
|
||||
for server_info in server_list:
|
||||
if server_info.get("server_name", "").lower() == cluster_id.lower():
|
||||
found_servers.append(server_info)
|
||||
|
||||
tasks = []
|
||||
for server_info in cluster.values():
|
||||
xui = AsyncApi(
|
||||
server_info["API_URL"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
if found_servers:
|
||||
cluster = found_servers
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
tasks.append(delete_client(xui, email, client_id))
|
||||
for server_info in cluster:
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
if panel_type == "remnawave":
|
||||
remna = RemnawaveAPI(server_info["api_url"])
|
||||
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||
if not logged_in:
|
||||
logger.error("Не удалось войти в Remnawave API")
|
||||
continue
|
||||
|
||||
success = await remna.delete_user(client_id)
|
||||
if success:
|
||||
logger.info(f"Клиент Remnawave {client_id} успешно удалён")
|
||||
else:
|
||||
logger.warning(f"Не удалось удалить клиента Remnawave {client_id}")
|
||||
continue
|
||||
|
||||
elif panel_type == "3x-ui":
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
if not inbound_id:
|
||||
logger.warning(
|
||||
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
|
||||
)
|
||||
continue
|
||||
|
||||
await delete_client(
|
||||
xui,
|
||||
int(inbound_id),
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
|
||||
else:
|
||||
logger.warning(f"Неизвестный тип панели '{panel_type}' для сервера {server_info.get('server_name')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Не удалось удалить ключ {client_id} в кластере {cluster_id}: {e}"
|
||||
)
|
||||
logger.error(f"Не удалось удалить ключ {client_id} в кластере/на сервере {cluster_id}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id):
|
||||
"""
|
||||
Пересоздаёт ключ на всех серверах указанного кластера (или сервера, если передано имя).
|
||||
Работает с панелями 3x-ui и Remnawave. Использует новый client_id от Remnawave для всех серверов (если пересоздан).
|
||||
Если SUPERNODE активен — email делается уникальным на каждый сервер.
|
||||
Возвращает кортеж: (новый client_id, remnawave ссылка или None).
|
||||
"""
|
||||
try:
|
||||
cluster = CLUSTERS.get(cluster_id)
|
||||
servers = await get_servers()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
|
||||
found_servers = []
|
||||
for _key, server_list in servers.items():
|
||||
for server_info in server_list:
|
||||
if server_info.get("server_name", "").lower() == cluster_id.lower():
|
||||
found_servers.append(server_info)
|
||||
if found_servers:
|
||||
cluster = found_servers
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
expire_iso = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=timezone.utc).isoformat()
|
||||
tasks = []
|
||||
for server_info in cluster.values():
|
||||
xui = AsyncApi(
|
||||
server_info["API_URL"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
|
||||
remnawave_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"]
|
||||
xui_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "3x-ui"]
|
||||
|
||||
remnawave_client_id = None
|
||||
remnawave_key = None
|
||||
|
||||
if remnawave_servers:
|
||||
inbound_ids = [s["inbound_id"] for s in remnawave_servers if s.get("inbound_id")]
|
||||
|
||||
remna = RemnawaveAPI(remnawave_servers[0]["api_url"])
|
||||
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||
if logged_in:
|
||||
await remna.delete_user(client_id)
|
||||
|
||||
user_data = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_iso,
|
||||
"telegramId": tg_id,
|
||||
"activeUserInbounds": inbound_ids,
|
||||
}
|
||||
|
||||
result = await remna.create_user(user_data)
|
||||
if result:
|
||||
remnawave_client_id = result.get("uuid")
|
||||
remnawave_key = result.get("subscriptionUrl")
|
||||
logger.info(f"[Update] Remnawave: клиент заново создан, новый UUID: {remnawave_client_id}")
|
||||
else:
|
||||
logger.error("[Update] Ошибка создания Remnawave клиента")
|
||||
else:
|
||||
logger.error("[Update] Не удалось авторизоваться в Remnawave")
|
||||
|
||||
if not remnawave_client_id:
|
||||
logger.warning(f"[Update] Remnawave client_id не получен. Используется исходный: {client_id}")
|
||||
remnawave_client_id = client_id
|
||||
|
||||
for server_info in xui_servers:
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
if not inbound_id:
|
||||
logger.warning(f"[Update] INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
|
||||
continue
|
||||
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
|
||||
if SUPERNODE:
|
||||
sub_id = email
|
||||
unique_email = f"{email}_{server_name.lower()}"
|
||||
else:
|
||||
sub_id = email
|
||||
unique_email = email
|
||||
|
||||
config = ClientConfig(
|
||||
client_id=remnawave_client_id,
|
||||
email=unique_email,
|
||||
tg_id=tg_id,
|
||||
limit_ip=LIMIT_IP,
|
||||
total_gb=TOTAL_GB,
|
||||
expiry_time=expiry_time,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
inbound_id=int(inbound_id),
|
||||
sub_id=sub_id,
|
||||
)
|
||||
|
||||
tasks.append(
|
||||
add_client(
|
||||
xui,
|
||||
client_id,
|
||||
email,
|
||||
tg_id,
|
||||
limit_ip=1,
|
||||
total_gb=TOTAL_GB,
|
||||
expiry_time=expiry_time,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
)
|
||||
)
|
||||
tasks.append(add_client(xui, config))
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
logger.info(
|
||||
f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}"
|
||||
)
|
||||
logger.info(f"[Update] Ключ {remnawave_client_id} обновлён на всех серверах кластера {cluster_id}")
|
||||
return remnawave_client_id, remnawave_key
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}"
|
||||
)
|
||||
logger.error(f"[Update Error] Ошибка при обновлении ключа {client_id} на {cluster_id}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def update_subscription(tg_id: int, email: str, session: Any, cluster_override: str = None) -> None:
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT k.key, k.expiry_time, k.email, k.server_id, k.client_id
|
||||
FROM keys k
|
||||
WHERE k.tg_id = $1 AND k.email = $2
|
||||
""",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
|
||||
if not record:
|
||||
raise ValueError(f"The key {email} does not exist in database")
|
||||
|
||||
expiry_time = record["expiry_time"]
|
||||
client_id = record["client_id"]
|
||||
old_cluster_id = record["server_id"]
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
await delete_key_from_cluster(old_cluster_id, email, client_id)
|
||||
|
||||
await session.execute(
|
||||
"DELETE FROM keys WHERE tg_id = $1 AND email = $2",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
new_cluster_id = cluster_override or await get_least_loaded_cluster()
|
||||
|
||||
new_client_id, remnawave_key = await update_key_on_cluster(tg_id, client_id, email, expiry_time, new_cluster_id)
|
||||
|
||||
servers = await get_servers()
|
||||
cluster_servers = servers.get(new_cluster_id, [])
|
||||
has_xui = any(s.get("panel_type", "").lower() == "3x-ui" for s in cluster_servers)
|
||||
|
||||
final_key_link = public_link if has_xui else None
|
||||
|
||||
await store_key(
|
||||
tg_id,
|
||||
new_client_id,
|
||||
email,
|
||||
expiry_time,
|
||||
key=final_key_link,
|
||||
remnawave_link=remnawave_key,
|
||||
server_id=new_cluster_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_traffic(session: Any, tg_id: int, email: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает трафик пользователя на всех серверах, где у него есть ключ (3x-ui и Remnawave).
|
||||
Для Remnawave трафик считается один раз и отображается как "Remnawave (общий):".
|
||||
"""
|
||||
query = "SELECT client_id, server_id FROM keys WHERE tg_id = $1 AND email = $2"
|
||||
rows = await session.fetch(query, tg_id, email)
|
||||
|
||||
if not rows:
|
||||
return {"status": "error", "message": "У пользователя нет активных ключей."}
|
||||
|
||||
server_ids = {row["server_id"] for row in rows}
|
||||
|
||||
query_servers = """
|
||||
SELECT server_name, cluster_name, api_url, panel_type
|
||||
FROM servers
|
||||
WHERE server_name = ANY($1) OR cluster_name = ANY($1)
|
||||
"""
|
||||
server_rows = await session.fetch(query_servers, list(server_ids))
|
||||
|
||||
if not server_rows:
|
||||
logger.error(f"Не найдено серверов для: {server_ids}")
|
||||
return {"status": "error", "message": f"Серверы не найдены: {', '.join(server_ids)}"}
|
||||
|
||||
servers_map = {row["server_name"]: row for row in server_rows}
|
||||
|
||||
user_traffic_data = {}
|
||||
tasks = []
|
||||
|
||||
remnawave_client_id = None
|
||||
remnawave_checked = False
|
||||
|
||||
async def fetch_traffic(server_info: dict, client_id: str) -> tuple[str, Any]:
|
||||
server_name = server_info["server_name"]
|
||||
api_url = server_info["api_url"]
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
|
||||
try:
|
||||
if panel_type == "3x-ui":
|
||||
xui = await get_xui_instance(api_url)
|
||||
traffic_info = await get_client_traffic(xui, client_id)
|
||||
if traffic_info["status"] == "success" and traffic_info["traffic"]:
|
||||
client_data = traffic_info["traffic"][0]
|
||||
used_gb = (client_data.up + client_data.down) / 1073741824
|
||||
return server_name, round(used_gb, 2)
|
||||
else:
|
||||
return server_name, "Ошибка получения трафика"
|
||||
else:
|
||||
return server_name, f"Неизвестная панель: {panel_type}"
|
||||
except Exception as e:
|
||||
return server_name, f"Ошибка: {e}"
|
||||
|
||||
for row in rows:
|
||||
client_id = row["client_id"]
|
||||
server_id = row["server_id"]
|
||||
|
||||
matched_servers = [
|
||||
s for s in servers_map.values() if s["server_name"] == server_id or s["cluster_name"] == server_id
|
||||
]
|
||||
for server_info in matched_servers:
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
|
||||
if panel_type == "remnawave" and not remnawave_checked:
|
||||
remnawave_client_id = client_id
|
||||
remnawave_api_url = server_info["api_url"]
|
||||
remnawave_checked = True
|
||||
elif panel_type == "3x-ui":
|
||||
tasks.append(fetch_traffic(server_info, client_id))
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for server, result in results:
|
||||
user_traffic_data[server] = result
|
||||
|
||||
if remnawave_client_id:
|
||||
try:
|
||||
remna = RemnawaveAPI(remnawave_api_url)
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
user_traffic_data["Remnawave (общий)"] = "Не удалось авторизоваться"
|
||||
else:
|
||||
user_data = await remna.get_user_by_uuid(remnawave_client_id)
|
||||
if not user_data:
|
||||
user_traffic_data["Remnawave (общий)"] = "Клиент не найден"
|
||||
else:
|
||||
used_bytes = user_data.get("usedTrafficBytes", 0)
|
||||
used_gb = round(used_bytes / 1073741824, 2)
|
||||
user_traffic_data["Remnawave (общий)"] = used_gb
|
||||
except Exception as e:
|
||||
user_traffic_data["Remnawave (общий)"] = f"Ошибка: {e}"
|
||||
|
||||
return {"status": "success", "traffic": user_traffic_data}
|
||||
|
||||
|
||||
async def toggle_client_on_cluster(cluster_id: str, email: str, client_id: str, enable: bool = True) -> dict[str, Any]:
|
||||
"""
|
||||
Включает или отключает клиента на всех серверах указанного кластера.
|
||||
|
||||
Args:
|
||||
cluster_id (str): ID кластера или имя сервера
|
||||
email (str): Email клиента
|
||||
client_id (str): UUID клиента
|
||||
enable (bool): True для включения, False для отключения
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Результат операции с информацией по каждому серверу
|
||||
"""
|
||||
try:
|
||||
servers = await get_servers()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
found_servers = []
|
||||
for _, server_list in servers.items():
|
||||
for server_info in server_list:
|
||||
if server_info.get("server_name", "").lower() == cluster_id.lower():
|
||||
found_servers.append(server_info)
|
||||
|
||||
if found_servers:
|
||||
cluster = found_servers
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
results = {}
|
||||
tasks = []
|
||||
|
||||
for server_info in cluster:
|
||||
xui = await get_xui_instance(server_info["api_url"])
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
|
||||
if not inbound_id:
|
||||
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
|
||||
results[server_name] = False
|
||||
continue
|
||||
|
||||
if SUPERNODE:
|
||||
unique_email = f"{email}_{server_name.lower()}"
|
||||
else:
|
||||
unique_email = email
|
||||
|
||||
tasks.append(toggle_client(xui, int(inbound_id), unique_email, client_id, enable))
|
||||
|
||||
task_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for server_info, result in zip(cluster, task_results, strict=False):
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Ошибка на сервере {server_name}: {result}")
|
||||
results[server_name] = False
|
||||
else:
|
||||
results[server_name] = result
|
||||
|
||||
status = "включен" if enable else "отключен"
|
||||
logger.info(f"Клиент {email} {status} на серверах кластера {cluster_id}")
|
||||
|
||||
return {"status": "success" if any(results.values()) else "error", "results": results}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при изменении состояния клиента {email} в кластере {cluster_id}: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
async def reset_traffic_in_cluster(cluster_id: str, email: str) -> None:
|
||||
"""
|
||||
Сбрасывает трафик клиента на всех серверах указанного кластера (или конкретного сервера).
|
||||
Работает с 3x-ui и Remnawave.
|
||||
|
||||
Args:
|
||||
cluster_id (str): ID кластера или имя сервера
|
||||
email (str): Email клиента (будет преобразован в уникальный для SUPERNODE)
|
||||
"""
|
||||
try:
|
||||
servers = await get_servers()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
found_servers = []
|
||||
for _, server_list in servers.items():
|
||||
for server_info in server_list:
|
||||
if server_info.get("server_name", "").lower() == cluster_id.lower():
|
||||
found_servers.append(server_info)
|
||||
|
||||
if found_servers:
|
||||
cluster = found_servers
|
||||
else:
|
||||
raise ValueError(f"Кластер или сервер с ID/именем {cluster_id} не найден.")
|
||||
|
||||
tasks = []
|
||||
remnawave_done = False
|
||||
|
||||
for server_info in cluster:
|
||||
panel_type = server_info.get("panel_type", "3x-ui").lower()
|
||||
server_name = server_info.get("server_name", "unknown")
|
||||
api_url = server_info.get("api_url")
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
|
||||
if panel_type == "remnawave" and not remnawave_done:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT client_id FROM keys WHERE email = $1 AND server_id = $2 LIMIT 1",
|
||||
email,
|
||||
cluster_id,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if not row:
|
||||
logger.warning(f"[Remnawave Reset] client_id не найден для {email} на {server_name}")
|
||||
continue
|
||||
|
||||
client_id = row["client_id"]
|
||||
|
||||
remna = RemnawaveAPI(api_url)
|
||||
logged_in = await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD)
|
||||
if not logged_in:
|
||||
logger.warning(f"[Reset Traffic] Не удалось авторизоваться в Remnawave ({server_name})")
|
||||
continue
|
||||
|
||||
tasks.append(remna.reset_user_traffic(client_id))
|
||||
remnawave_done = True
|
||||
continue
|
||||
|
||||
if panel_type == "3x-ui":
|
||||
if not inbound_id:
|
||||
logger.warning(f"INBOUND_ID отсутствует для сервера {server_name}. Пропуск.")
|
||||
continue
|
||||
|
||||
xui = await get_xui_instance(api_url)
|
||||
|
||||
unique_email = f"{email}_{server_name.lower()}" if SUPERNODE else email
|
||||
tasks.append(xui.client.reset_stats(int(inbound_id), unique_email))
|
||||
else:
|
||||
logger.warning(f"[Reset Traffic] Неизвестный тип панели '{panel_type}' на {server_name}")
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info(f"[Reset Traffic] Трафик клиента {email} успешно сброшен в кластере {cluster_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Reset Traffic] Ошибка при сбросе трафика клиента {email} в кластере {cluster_id}: {e}")
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
CONNECT_PHONE_BUTTON,
|
||||
ENABLE_DELETE_KEY_BUTTON,
|
||||
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
|
||||
QRCODE,
|
||||
TOGGLE_CLIENT,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from database import (
|
||||
get_key_details,
|
||||
get_keys,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
ADD_SUB,
|
||||
ALIAS,
|
||||
BACK,
|
||||
CHANGE_LOCATION,
|
||||
CONNECT_DEVICE,
|
||||
CONNECT_PHONE,
|
||||
DELETE,
|
||||
FREEZE,
|
||||
MAIN_MENU,
|
||||
PC_BUTTON,
|
||||
QR,
|
||||
RENEW,
|
||||
RENEW_FULL,
|
||||
TV_BUTTON,
|
||||
UNFREEZE,
|
||||
)
|
||||
from handlers.texts import (
|
||||
FROZEN_SUBSCRIPTION_MSG,
|
||||
NO_SUBSCRIPTIONS_MSG,
|
||||
key_message,
|
||||
)
|
||||
from handlers.utils import (
|
||||
edit_or_send_message,
|
||||
format_days,
|
||||
format_hours,
|
||||
format_minutes,
|
||||
get_russian_month,
|
||||
handle_error,
|
||||
is_full_remnawave_cluster,
|
||||
)
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class RenameKeyState(StatesGroup):
|
||||
waiting_for_new_alias = State()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "view_keys")
|
||||
@router.message(F.text == "/subs")
|
||||
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
|
||||
if isinstance(callback_query_or_message, CallbackQuery):
|
||||
target_message = callback_query_or_message.message
|
||||
else:
|
||||
target_message = callback_query_or_message
|
||||
|
||||
try:
|
||||
records = await get_keys(target_message.chat.id, session)
|
||||
inline_keyboard, response_message = build_keys_response(records)
|
||||
image_path = os.path.join("img", "pic_keys.jpg")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=target_message, text=response_message, reply_markup=inline_keyboard, media_path=image_path
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = f"Ошибка при получении ключей: {e}"
|
||||
await target_message.answer(text=error_message)
|
||||
|
||||
|
||||
def build_keys_response(records):
|
||||
"""
|
||||
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
if records:
|
||||
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
|
||||
for record in records:
|
||||
alias = record.get("alias")
|
||||
email = record["email"]
|
||||
client_id = record["client_id"]
|
||||
expiry_time = record.get("expiry_time")
|
||||
|
||||
key_display = html.escape(alias.strip() if alias else email)
|
||||
|
||||
if expiry_time:
|
||||
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
|
||||
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
|
||||
else:
|
||||
formatted_date_full = "без срока действия"
|
||||
|
||||
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
|
||||
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
|
||||
builder.row(key_button, rename_button)
|
||||
|
||||
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
|
||||
|
||||
response_message += "</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
|
||||
else:
|
||||
response_message = NO_SUBSCRIPTIONS_MSG
|
||||
|
||||
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
inline_keyboard = builder.as_markup()
|
||||
return inline_keyboard, response_message
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("rename_key|"))
|
||||
async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
|
||||
client_id = callback.data.split("|")[1]
|
||||
await state.set_state(RenameKeyState.waiting_for_new_alias)
|
||||
await state.update_data(client_id=client_id, target_message=callback.message)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback.message,
|
||||
text="✏️ Введите новое имя подписки (до 10 символов):",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
|
||||
async def handle_new_alias_input(message: Message, state: FSMContext, session: Any):
|
||||
alias = message.text.strip()
|
||||
|
||||
if len(alias) > 10:
|
||||
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
|
||||
return
|
||||
|
||||
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
|
||||
await message.answer(
|
||||
"❌ Введены недопустимые символы или имя пустое. Используйте только буквы, цифры и @._-\nПовторите ввод."
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
client_id = data.get("client_id")
|
||||
|
||||
try:
|
||||
await session.execute(
|
||||
"UPDATE keys SET alias = $1 WHERE tg_id = $2 AND client_id = $3",
|
||||
alias,
|
||||
message.chat.id,
|
||||
client_id,
|
||||
)
|
||||
except Exception as e:
|
||||
await message.answer("❌ Не удалось переименовать подписку.")
|
||||
logger.error(f"Ошибка при обновлении alias: {e}")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
await process_callback_or_message_view_keys(message, session)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("view_key|"))
|
||||
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await get_key_details(key_name, session)
|
||||
if not record:
|
||||
await callback_query.message.answer(text="<b>Информация о подписке не найдена.</b>")
|
||||
return
|
||||
|
||||
is_frozen = record["is_frozen"]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
if not os.path.isfile(image_path):
|
||||
await callback_query.message.answer("Файл изображения не найден.")
|
||||
return
|
||||
|
||||
if is_frozen:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=UNFREEZE,
|
||||
callback_data=f"unfreeze_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=FROZEN_SUBSCRIPTION_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
)
|
||||
return
|
||||
|
||||
key = record.get("key")
|
||||
remnawave_link = record.get("remnawave_link")
|
||||
final_link = key or remnawave_link
|
||||
|
||||
expiry_time = record["expiry_time"]
|
||||
server_name = record["server_id"]
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
time_left = expiry_date - datetime.utcnow()
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла"
|
||||
else:
|
||||
total_seconds = int(time_left.total_seconds())
|
||||
days = total_seconds // 86400
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
days_left_message = (
|
||||
f"Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
|
||||
)
|
||||
|
||||
formatted_expiry_date = f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
|
||||
response_message = key_message(
|
||||
final_link,
|
||||
formatted_expiry_date,
|
||||
days_left_message,
|
||||
server_name,
|
||||
server_name if USE_COUNTRY_SELECTION else None,
|
||||
)
|
||||
|
||||
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить подписку",
|
||||
callback_data=f"update_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
|
||||
if is_full_remnawave and final_link:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
web_app=WebAppInfo(url=final_link),
|
||||
)
|
||||
)
|
||||
else:
|
||||
if CONNECT_PHONE_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_PHONE,
|
||||
callback_data=f"connect_phone|{key_name}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
|
||||
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=CONNECT_DEVICE,
|
||||
callback_data=f"connect_device|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
if QRCODE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=QR,
|
||||
callback_data=f"show_qr|{key_name}",
|
||||
)
|
||||
)
|
||||
if ENABLE_DELETE_KEY_BUTTON:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=RENEW, callback_data=f"renew_key|{key_name}"),
|
||||
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
|
||||
)
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}"))
|
||||
|
||||
if USE_COUNTRY_SELECTION:
|
||||
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
|
||||
|
||||
if TOGGLE_CLIENT:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=FREEZE,
|
||||
callback_data=f"freeze_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
)
|
||||
except Exception as e:
|
||||
await handle_error(
|
||||
tg_id,
|
||||
callback_query,
|
||||
f"Ошибка при получении информации о ключе: {e}",
|
||||
)
|
||||
+76
-632
@@ -1,676 +1,120 @@
|
||||
import asyncio
|
||||
import locale
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import BufferedInputFile
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from bot import bot
|
||||
from config import CLUSTERS, CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, PUBLIC_LINK, TOTAL_GB
|
||||
from database import delete_key, get_balance, store_key, update_balance, update_key_expiry
|
||||
from handlers.keys.key_utils import delete_key_from_cluster, delete_key_from_db, renew_key_in_cluster, update_key_on_cluster
|
||||
from handlers.texts import INSUFFICIENT_FUNDS_MSG, KEY_NOT_FOUND_MSG, NO_KEYS, PLAN_SELECTION_MSG, RENEWAL_PLANS, SUCCESS_RENEWAL_MSG, key_message
|
||||
from handlers.utils import get_least_loaded_cluster, handle_error
|
||||
from database import (
|
||||
delete_key,
|
||||
get_key_details,
|
||||
get_servers,
|
||||
)
|
||||
from handlers.buttons import (
|
||||
APPLY,
|
||||
BACK,
|
||||
CANCEL,
|
||||
)
|
||||
from handlers.keys.key_utils import (
|
||||
delete_key_from_cluster,
|
||||
update_subscription,
|
||||
)
|
||||
from handlers.keys.key_view import process_callback_view_key
|
||||
from handlers.texts import (
|
||||
DELETE_KEY_CONFIRM_MSG,
|
||||
KEY_DELETED_MSG_SIMPLE,
|
||||
)
|
||||
from handlers.utils import edit_or_send_message, handle_error
|
||||
from logger import logger
|
||||
|
||||
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "view_keys")
|
||||
async def process_callback_view_keys(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
records = await conn.fetch(
|
||||
"""
|
||||
SELECT email, client_id FROM keys WHERE tg_id = $1
|
||||
""",
|
||||
tg_id,
|
||||
)
|
||||
|
||||
if records:
|
||||
buttons = []
|
||||
for record in records:
|
||||
key_name = record["email"]
|
||||
client_id = record["client_id"]
|
||||
button = types.InlineKeyboardButton(
|
||||
text=f"🔑 {key_name}",
|
||||
callback_data=f"view_key|{key_name}|{client_id}",
|
||||
)
|
||||
buttons.append([button])
|
||||
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data="view_profile"
|
||||
)
|
||||
buttons.append([back_button])
|
||||
|
||||
inline_keyboard = types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
response_message = (
|
||||
"<b>🔑 Список ваших устройств</b>\n\n"
|
||||
"<i>👇 Выберите устройство для управления подпиской:</i>"
|
||||
)
|
||||
|
||||
image_path = os.path.join(os.path.dirname(__file__), "pic_keys.jpg")
|
||||
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
await bot.send_photo(
|
||||
chat_id=tg_id,
|
||||
photo=BufferedInputFile(
|
||||
image_file.read(), filename="pic_keys.jpg"
|
||||
),
|
||||
caption=response_message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=inline_keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text=response_message,
|
||||
reply_markup=inline_keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
else:
|
||||
response_message = NO_KEYS
|
||||
create_key_button = types.InlineKeyboardButton(
|
||||
text="➕ Создать подписку", callback_data="create_key"
|
||||
)
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data="view_profile"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[create_key_button], [back_button]]
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
image_path = os.path.join(os.path.dirname(__file__), "pic_keys.jpg")
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
await bot.send_photo(
|
||||
chat_id=tg_id,
|
||||
photo=BufferedInputFile(
|
||||
image_file.read(), filename="pic_keys.jpg"
|
||||
),
|
||||
caption=response_message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text=response_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при получении ключей: {e}")
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("view_key|"))
|
||||
async def process_callback_view_key(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
key_name, client_id = (
|
||||
callback_query.data.split("|")[1],
|
||||
callback_query.data.split("|")[2],
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow(
|
||||
"""
|
||||
SELECT k.expiry_time, k.server_id, k.key
|
||||
FROM keys k
|
||||
WHERE k.tg_id = $1 AND k.email = $2
|
||||
""",
|
||||
tg_id,
|
||||
key_name,
|
||||
)
|
||||
|
||||
if record:
|
||||
key = record["key"]
|
||||
expiry_time = record["expiry_time"]
|
||||
server_name = record["server_id"]
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = (
|
||||
"<b>🕒 Статус подписки:</b>\n🔴 Истекла\nОсталось часов: 0"
|
||||
)
|
||||
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>"
|
||||
|
||||
formatted_expiry_date = expiry_date.strftime("%d %B %Y года")
|
||||
response_message = key_message(
|
||||
key, formatted_expiry_date, days_left_message, server_name
|
||||
)
|
||||
|
||||
download_android_button = types.InlineKeyboardButton(
|
||||
text="🤖 Скачать", url=DOWNLOAD_ANDROID
|
||||
)
|
||||
download_iphone_button = types.InlineKeyboardButton(
|
||||
text="🍏 Скачать", url=DOWNLOAD_IOS
|
||||
)
|
||||
|
||||
connect_iphone_button = types.InlineKeyboardButton(
|
||||
text="🍏 Подключить", url=f"{CONNECT_IOS}{key}"
|
||||
)
|
||||
connect_android_button = types.InlineKeyboardButton(
|
||||
text="🤖 Подключить", url=f"{CONNECT_ANDROID}{key}"
|
||||
)
|
||||
|
||||
connect_pc_button = types.InlineKeyboardButton(
|
||||
text="💻 Windows/Linux", callback_data=f"connect_pc|{key}"
|
||||
)
|
||||
|
||||
renew_button = types.InlineKeyboardButton(
|
||||
text="⏳ Продлить", callback_data=f"renew_key|{client_id}"
|
||||
)
|
||||
delete_button = types.InlineKeyboardButton(
|
||||
text="❌ Удалить", callback_data=f"delete_key|{client_id}"
|
||||
)
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text="🔙 Назад в профиль", callback_data="view_profile"
|
||||
)
|
||||
|
||||
inline_keyboard = [
|
||||
[download_iphone_button, download_android_button],
|
||||
[connect_iphone_button, connect_android_button],
|
||||
[connect_pc_button],
|
||||
[renew_button, delete_button],
|
||||
]
|
||||
|
||||
if not key.startswith(PUBLIC_LINK):
|
||||
update_subscription_button = types.InlineKeyboardButton(
|
||||
text="🔄 Обновить подписку",
|
||||
callback_data=f"update_subscription|{client_id}",
|
||||
)
|
||||
inline_keyboard.append([update_subscription_button])
|
||||
|
||||
inline_keyboard.append([back_button])
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=inline_keyboard)
|
||||
|
||||
image_path = os.path.join(os.path.dirname(__file__), "pic_view.jpg")
|
||||
|
||||
if not os.path.isfile(image_path):
|
||||
await bot.send_message(tg_id, "Файл изображения не найден.")
|
||||
return
|
||||
|
||||
with open(image_path, "rb") as image_file:
|
||||
await bot.send_photo(
|
||||
chat_id=tg_id,
|
||||
photo=BufferedInputFile(
|
||||
image_file.read(), filename="pic_view.jpg"
|
||||
),
|
||||
caption=response_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text="<b>Информация о подписке не найдена.</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(
|
||||
tg_id, callback_query, f"Ошибка при получении информации о ключе: {e}"
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("update_subscription|"))
|
||||
async def process_callback_update_subscription(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
client_id = callback_query.data.split("|")[1]
|
||||
async def process_callback_update_subscription(callback_query: CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
email = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow(
|
||||
"""
|
||||
SELECT k.key, k.expiry_time, k.email, k.server_id
|
||||
FROM keys k
|
||||
WHERE k.tg_id = $1 AND k.client_id = $2
|
||||
""",
|
||||
tg_id,
|
||||
client_id,
|
||||
)
|
||||
|
||||
if record:
|
||||
expiry_time = record["expiry_time"]
|
||||
email = record["email"]
|
||||
public_link = f"{PUBLIC_LINK}{email}"
|
||||
|
||||
try:
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM keys
|
||||
WHERE tg_id = $1 AND client_id = $2
|
||||
""",
|
||||
tg_id,
|
||||
client_id,
|
||||
)
|
||||
except Exception as delete_error:
|
||||
await bot.send_message(
|
||||
tg_id, f"Ошибка при удалении старой подписки: {delete_error}"
|
||||
)
|
||||
return
|
||||
|
||||
least_loaded_cluster_id = await get_least_loaded_cluster()
|
||||
|
||||
tasks = []
|
||||
tasks.append(
|
||||
update_key_on_cluster(
|
||||
tg_id, client_id, email, expiry_time, least_loaded_cluster_id
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
await store_key(
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_time,
|
||||
public_link,
|
||||
server_id=least_loaded_cluster_id,
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
response_message = f"Ваша подписка {email} обновлена!"
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text="🔙 Назад в профиль", callback_data="view_profile"
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await bot.send_message(
|
||||
tg_id, response_message, reply_markup=keyboard, parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
await bot.send_message(
|
||||
tg_id, "<b>Ключ не найден в базе данных.</b>", parse_mode="HTML"
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
await callback_query.message.delete()
|
||||
except TelegramBadRequest as e:
|
||||
if "message can't be deleted" not in str(e):
|
||||
raise
|
||||
|
||||
await update_subscription(tg_id, email, session)
|
||||
await process_callback_view_key(callback_query, session)
|
||||
except Exception as e:
|
||||
await handle_error(
|
||||
tg_id, callback_query, f"Ошибка при обновлении подписки: {e}"
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
logger.error(f"Ошибка при обновлении ключа {email} пользователем: {e}")
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при обновлении подписки: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_key|"))
|
||||
async def process_callback_delete_key(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
async def process_callback_delete_key(callback_query: CallbackQuery):
|
||||
client_id = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
confirmation_keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
text=APPLY,
|
||||
callback_data=f"confirm_delete|{client_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Нет, отменить", callback_data="view_keys"
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=CANCEL, callback_data="view_keys")],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text="<b>Вы уверены, что хотите удалить ключ?</b>",
|
||||
reply_markup=confirmation_keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
if callback_query.message.caption:
|
||||
await callback_query.message.edit_caption(
|
||||
caption=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
|
||||
)
|
||||
else:
|
||||
await callback_query.message.edit_text(text=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard)
|
||||
|
||||
except Exception as e:
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text=f"<b>Ошибка при удалении ключа:</b> {e}",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_key|"))
|
||||
async def process_callback_renew_key(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
client_id = callback_query.data.split("|")[1]
|
||||
|
||||
try:
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow(
|
||||
"SELECT email, expiry_time FROM keys WHERE client_id = $1", client_id
|
||||
)
|
||||
|
||||
if record:
|
||||
expiry_time = record["expiry_time"]
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'📅 1 месяц ({RENEWAL_PLANS["1"]["price"]} руб.)',
|
||||
callback_data=f"renew_plan|1|{client_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'📅 3 месяца ({RENEWAL_PLANS["3"]["price"]} руб.)',
|
||||
callback_data=f"renew_plan|3|{client_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'📅 6 месяцев ({RENEWAL_PLANS["6"]["price"]} руб.)',
|
||||
callback_data=f"renew_plan|6|{client_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'📅 12 месяцев ({RENEWAL_PLANS["12"]["price"]} руб.)',
|
||||
callback_data=f"renew_plan|12|{client_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data="view_profile"
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
response_message = PLAN_SELECTION_MSG.format(
|
||||
balance=balance,
|
||||
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text=response_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
else:
|
||||
response_message = "<b>Ключ не найден.</b>"
|
||||
await bot.send_message(
|
||||
chat_id=tg_id, text=response_message, parse_mode="HTML"
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text=f"<b>Ошибка при выборе плана:</b> {e}",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
logger.error(f"Ошибка при обработке запроса на удаление ключа {client_id}: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete|"))
|
||||
async def process_callback_confirm_delete(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
client_id = callback_query.data.split("|")[1]
|
||||
|
||||
async def process_callback_confirm_delete(callback_query: CallbackQuery, session: Any):
|
||||
email = callback_query.data.split("|")[1]
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow(
|
||||
"SELECT email FROM keys WHERE client_id = $1", client_id
|
||||
record = await get_key_details(email, session)
|
||||
if record:
|
||||
client_id = record["client_id"]
|
||||
response_message = KEY_DELETED_MSG_SIMPLE
|
||||
back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await delete_key(client_id, session)
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
|
||||
)
|
||||
|
||||
if record:
|
||||
email = record["email"]
|
||||
response_message = "Ключ успешно удален."
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text="Назад", callback_data="view_keys"
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
servers = await get_servers(session)
|
||||
|
||||
await delete_key(client_id)
|
||||
await bot.edit_message_text(
|
||||
response_message,
|
||||
chat_id=tg_id,
|
||||
message_id=callback_query.message.message_id,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
async def delete_key_from_servers():
|
||||
try:
|
||||
tasks = []
|
||||
for cluster_id, cluster in CLUSTERS.items():
|
||||
tasks.append(
|
||||
delete_key_from_cluster(cluster_id, email, client_id)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {client_id}: {e}")
|
||||
|
||||
asyncio.create_task(delete_key_from_servers())
|
||||
|
||||
await delete_key_from_db(client_id)
|
||||
|
||||
else:
|
||||
response_message = "Ключ не найден или уже удален."
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text="Назад", callback_data="view_keys"
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await bot.edit_message_text(
|
||||
response_message,
|
||||
chat_id=tg_id,
|
||||
message_id=callback_query.message.message_id,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await bot.edit_message_text(
|
||||
f"Ошибка при удалении ключа: {e}",
|
||||
chat_id=tg_id,
|
||||
message_id=callback_query.message.message_id,
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||
async def process_callback_renew_plan(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
plan, client_id = (
|
||||
callback_query.data.split("|")[1],
|
||||
callback_query.data.split("|")[2],
|
||||
)
|
||||
days_to_extend = 30 * int(plan)
|
||||
|
||||
gb_multiplier = {"1": 1, "3": 3, "6": 6, "12": 12}
|
||||
total_gb = TOTAL_GB * gb_multiplier.get(plan, 1) if TOTAL_GB > 0 else 0
|
||||
|
||||
try:
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow(
|
||||
"SELECT email, expiry_time FROM keys WHERE client_id = $1", client_id
|
||||
)
|
||||
|
||||
if record:
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
current_time = datetime.utcnow().timestamp() * 1000
|
||||
|
||||
if expiry_time <= current_time:
|
||||
new_expiry_time = int(
|
||||
current_time
|
||||
+ timedelta(days=days_to_extend).total_seconds() * 1000
|
||||
)
|
||||
else:
|
||||
new_expiry_time = int(
|
||||
expiry_time
|
||||
+ timedelta(days=days_to_extend).total_seconds() * 1000
|
||||
)
|
||||
|
||||
cost = RENEWAL_PLANS[plan]["price"]
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
if balance < cost:
|
||||
replenish_button = types.InlineKeyboardButton(
|
||||
text="Пополнить баланс", callback_data="pay"
|
||||
)
|
||||
view_profile = types.InlineKeyboardButton(
|
||||
text="👤 Личный кабинет", callback_data="view_profile"
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[replenish_button], [view_profile]]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
tg_id,
|
||||
INSUFFICIENT_FUNDS_MSG,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
response_message = SUCCESS_RENEWAL_MSG.format(
|
||||
months=RENEWAL_PLANS[plan]["months"]
|
||||
)
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text="Назад", callback_data="view_profile"
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await bot.send_message(
|
||||
tg_id, response_message, reply_markup=keyboard, parse_mode="HTML"
|
||||
)
|
||||
|
||||
async def renew_key_on_servers():
|
||||
async def delete_key_from_servers():
|
||||
try:
|
||||
tasks = []
|
||||
for cluster_id in CLUSTERS:
|
||||
task = asyncio.create_task(
|
||||
renew_key_in_cluster(
|
||||
cluster_id, email, client_id, new_expiry_time, total_gb
|
||||
)
|
||||
)
|
||||
tasks.append(task)
|
||||
for cluster_id, _cluster in servers.items():
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {client_id}: {e}")
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
await update_balance(tg_id, -cost)
|
||||
await update_key_expiry(client_id, new_expiry_time)
|
||||
|
||||
await renew_key_on_servers()
|
||||
|
||||
else:
|
||||
await bot.send_message(tg_id, KEY_NOT_FOUND_MSG, parse_mode="HTML")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
asyncio.create_task(delete_key_from_servers())
|
||||
|
||||
await delete_key(client_id, session)
|
||||
else:
|
||||
response_message = "Ключ не найден или уже удален."
|
||||
back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
|
||||
)
|
||||
except Exception as e:
|
||||
await bot.send_message(
|
||||
tg_id, f"Ошибка при продлении ключа: {e}", parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
logger.error(e)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
+203
-50
@@ -1,75 +1,228 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from config import CLUSTERS
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
PROJECT_NAME,
|
||||
SUPERNODE,
|
||||
SUPPORT_CHAT_URL,
|
||||
TOTAL_GB,
|
||||
USERNAME_BOT,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from database import get_key_details, get_servers
|
||||
from handlers.utils import convert_to_bytes
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def fetch_url_content(url):
|
||||
async def fetch_url_content(url: str, identifier: str) -> list[str]:
|
||||
try:
|
||||
logger.debug(f"Получение URL: {url}")
|
||||
async with aiohttp.ClientSession() as session:
|
||||
timeout = aiohttp.ClientTimeout(total=5)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, ssl=False) as response:
|
||||
if response.status == 200:
|
||||
content = await response.text()
|
||||
logger.debug(f"Успешно получен контент с {url}")
|
||||
return base64.b64decode(content).decode("utf-8").split("\n")
|
||||
else:
|
||||
logger.error(
|
||||
f"Не удалось получить {url}, статус: {response.status}"
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении {url}: {e}")
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def combine_unique_lines(urls, query_string):
|
||||
all_lines = []
|
||||
logger.debug(f"Начинаем объединение подписок для запроса: {query_string}")
|
||||
async def combine_unique_lines(urls: list[str], identifier: str, query_string: str) -> list[str]:
|
||||
if SUPERNODE:
|
||||
if not urls:
|
||||
return []
|
||||
url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0]
|
||||
return await fetch_url_content(url_with_query, identifier)
|
||||
|
||||
urls_with_query = [f"{url}?{query_string}" for url in urls]
|
||||
logger.debug(f"Составлены URL-адреса: {urls_with_query}")
|
||||
|
||||
for url in urls_with_query:
|
||||
lines = await fetch_url_content(url)
|
||||
all_lines.extend(lines)
|
||||
|
||||
all_lines = list(set(filter(None, all_lines)))
|
||||
logger.debug(
|
||||
f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов"
|
||||
)
|
||||
|
||||
return all_lines
|
||||
urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls]
|
||||
tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
all_lines = set()
|
||||
for lines in results:
|
||||
all_lines.update(filter(None, lines))
|
||||
return list(all_lines)
|
||||
|
||||
|
||||
async def handle_subscription(request):
|
||||
email = request.match_info["email"]
|
||||
logger.info(f"Получен запрос на подписку для email: {email}")
|
||||
|
||||
async def get_subscription_urls(server_id: str, email: str, conn, include_remnawave_key: str = None) -> list[str]:
|
||||
urls = []
|
||||
for cluster in CLUSTERS.values():
|
||||
for server in cluster.values():
|
||||
server_subscription_url = f"{server['SUBSCRIPTION']}/{email}"
|
||||
urls.append(server_subscription_url)
|
||||
if USE_COUNTRY_SELECTION:
|
||||
server_data = await conn.fetchrow("SELECT subscription_url FROM servers WHERE server_name = $1", server_id)
|
||||
if server_data and server_data["subscription_url"]:
|
||||
urls.append(f"{server_data['subscription_url']}/{email}")
|
||||
else:
|
||||
servers = await get_servers(conn)
|
||||
cluster_servers = servers.get(server_id, [])
|
||||
for server in cluster_servers:
|
||||
if url := server.get("subscription_url"):
|
||||
urls.append(f"{url}/{email}")
|
||||
|
||||
query_string = request.query_string
|
||||
logger.debug(f"Извлечен query string: {query_string}")
|
||||
if include_remnawave_key:
|
||||
urls.append(include_remnawave_key)
|
||||
|
||||
combined_subscriptions = await combine_unique_lines(urls, query_string)
|
||||
return urls
|
||||
|
||||
base64_encoded = base64.b64encode(
|
||||
"\n".join(combined_subscriptions).encode("utf-8")
|
||||
).decode("utf-8")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "inline",
|
||||
"profile-update-interval": "7",
|
||||
"profile-title": email,
|
||||
}
|
||||
def calculate_traffic(cleaned_subscriptions: list[str], expiry_time_ms: int | None) -> str:
|
||||
expire_timestamp = int(expiry_time_ms / 1000) if expiry_time_ms else 0
|
||||
if TOTAL_GB != 0:
|
||||
country_remaining = {}
|
||||
for line in cleaned_subscriptions:
|
||||
if "#" not in line:
|
||||
continue
|
||||
try:
|
||||
_, meta = line.split("#", 1)
|
||||
except ValueError:
|
||||
continue
|
||||
parts = meta.split("-")
|
||||
country = parts[0].strip()
|
||||
remaining_str = parts[1].strip() if len(parts) == 2 else ""
|
||||
if remaining_str:
|
||||
remaining_str = remaining_str.replace(",", ".")
|
||||
m_total = re.search(r"([\d\.]+)\s*([GMKTB]B)", remaining_str, re.IGNORECASE)
|
||||
if m_total:
|
||||
value = float(m_total.group(1))
|
||||
unit = m_total.group(2).upper()
|
||||
remaining_bytes = convert_to_bytes(value, unit)
|
||||
country_remaining[country] = remaining_bytes
|
||||
num_countries = len(country_remaining)
|
||||
issued_per_country = TOTAL_GB
|
||||
total_traffic_bytes = issued_per_country * num_countries
|
||||
consumed_traffic_bytes = total_traffic_bytes - sum(country_remaining.values())
|
||||
if consumed_traffic_bytes < 0:
|
||||
consumed_traffic_bytes = 0
|
||||
else:
|
||||
consumed_traffic_bytes = 1
|
||||
total_traffic_bytes = 0
|
||||
return f"upload=0; download={consumed_traffic_bytes}; total={total_traffic_bytes}; expire={expire_timestamp}"
|
||||
|
||||
logger.info(f"Возвращаем объединенные подписки для email: {email}")
|
||||
return web.Response(text=base64_encoded, headers=headers)
|
||||
|
||||
def clean_subscription_line(line: str) -> str:
|
||||
if "#" not in line:
|
||||
return line
|
||||
try:
|
||||
base, meta = line.split("#", 1)
|
||||
except ValueError:
|
||||
return line
|
||||
parts = meta.split("-")
|
||||
country = parts[0].strip() if parts else ""
|
||||
traffic = ""
|
||||
for part in parts[1:]:
|
||||
part_decoded = urllib.parse.unquote(part).strip()
|
||||
if re.search(r"\d+(?:[.,]\d+)?\s*(?:GB|MB|KB|TB)", part_decoded, re.IGNORECASE):
|
||||
traffic = part_decoded
|
||||
break
|
||||
meta_clean = f"{country} - {traffic}" if traffic else country
|
||||
return base + "#" + meta_clean
|
||||
|
||||
|
||||
def format_time_left(expiry_time_ms: int | None) -> str:
|
||||
if not expiry_time_ms:
|
||||
return "N/A"
|
||||
now_ms = int(time.time() * 1000)
|
||||
remaining_sec = max((expiry_time_ms - now_ms) / 1000, 0)
|
||||
days = int(remaining_sec // 86400)
|
||||
hours = int((remaining_sec % 86400) // 3600)
|
||||
return f"{days}D,{hours}H ⏳" if days else f"{hours}H ⏳"
|
||||
|
||||
|
||||
def prepare_headers(
|
||||
user_agent: str, project_name: str, subscription_info: str, subscription_userinfo: str
|
||||
) -> dict[str, str]:
|
||||
if "Happ" in user_agent:
|
||||
encoded_project_name = f"{project_name}"
|
||||
announce_str = f"↖️Бот | {subscription_info} | Поддержка↗️"
|
||||
return {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "inline",
|
||||
"profile-update-interval": "3",
|
||||
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
"support-url": SUPPORT_CHAT_URL,
|
||||
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
|
||||
"profile-web-page-url": f"https://t.me/{USERNAME_BOT}",
|
||||
"subscription-userinfo": subscription_userinfo,
|
||||
}
|
||||
elif "Hiddify" in user_agent:
|
||||
parts = subscription_info.split(" - ")[0].split(": ")
|
||||
key_info = parts[1] if len(parts) > 1 else parts[0]
|
||||
encoded_project_name = f"{project_name}\n📄 Подписка: {key_info}"
|
||||
return {
|
||||
"profile-update-interval": "3",
|
||||
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
"subscription-userinfo": subscription_userinfo,
|
||||
}
|
||||
elif "v2raytun" in user_agent:
|
||||
encoded_project_name = f"{project_name}\n{subscription_info}"
|
||||
announce_str = "🔑 Выберите сервер ⬇️ | 💬 Поддержка ➡️"
|
||||
return {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "inline",
|
||||
"update-always": "true",
|
||||
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
|
||||
"announce-url": f"{SUPPORT_CHAT_URL}",
|
||||
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
}
|
||||
else:
|
||||
encoded_project_name = f"{project_name}\n{subscription_info}"
|
||||
return {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "inline",
|
||||
"profile-update-interval": "3",
|
||||
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
|
||||
}
|
||||
|
||||
|
||||
async def handle_subscription(request: web.Request) -> web.Response:
|
||||
email = request.match_info.get("email")
|
||||
tg_id = request.match_info.get("tg_id")
|
||||
|
||||
if not email or not tg_id:
|
||||
return web.Response(text="❌ Неверные параметры запроса.", status=400)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
client_data = await get_key_details(email, conn)
|
||||
if not client_data:
|
||||
return web.Response(text="❌ Клиент с таким email не найден.", status=404)
|
||||
|
||||
stored_tg_id = client_data.get("tg_id")
|
||||
server_id = client_data["server_id"]
|
||||
|
||||
if int(tg_id) != int(stored_tg_id):
|
||||
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
|
||||
|
||||
expiry_time_ms = client_data.get("expiry_time")
|
||||
time_left = format_time_left(expiry_time_ms)
|
||||
|
||||
urls = await get_subscription_urls(
|
||||
server_id, email, conn, include_remnawave_key=client_data.get("remnawave_link")
|
||||
)
|
||||
|
||||
if not urls:
|
||||
return web.Response(text="❌ Сервер не найден.", status=404)
|
||||
|
||||
query_string = request.query_string
|
||||
combined_subscriptions = await combine_unique_lines(urls, tg_id or email, query_string)
|
||||
random.shuffle(combined_subscriptions)
|
||||
|
||||
cleaned_subscriptions = [clean_subscription_line(line) for line in combined_subscriptions]
|
||||
|
||||
base64_encoded = base64.b64encode("\n".join(cleaned_subscriptions).encode("utf-8")).decode("utf-8")
|
||||
subscription_info = f"📄 Подписка: {email} - {time_left}"
|
||||
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms)
|
||||
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
|
||||
|
||||
return web.Response(text=base64_encoded, headers=headers)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from client import add_client
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, CLUSTERS, DATABASE_URL, PUBLIC_LINK, TRIAL_TIME
|
||||
from database import store_key
|
||||
from handlers.texts import INSTRUCTIONS
|
||||
from handlers.utils import generate_random_email, get_least_loaded_cluster
|
||||
|
||||
|
||||
async def create_trial_key(tg_id: int):
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
client_id = str(uuid.uuid4())
|
||||
email = generate_random_email()
|
||||
|
||||
public_link = f"{PUBLIC_LINK}{email}"
|
||||
instructions = INSTRUCTIONS
|
||||
|
||||
result = {"key": public_link, "instructions": instructions}
|
||||
|
||||
asyncio.create_task(
|
||||
generate_and_store_keys(tg_id, client_id, email, public_link)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def generate_and_store_keys(
|
||||
tg_id: int, client_id: str, email: str, public_link: str
|
||||
):
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
expiry_time = current_time + timedelta(days=TRIAL_TIME, hours=3)
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
|
||||
tasks = []
|
||||
for server_id, server in CLUSTERS[least_loaded_cluster].items():
|
||||
task = create_key_on_server(
|
||||
least_loaded_cluster,
|
||||
server_id,
|
||||
client_id,
|
||||
email,
|
||||
tg_id,
|
||||
expiry_timestamp,
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
await store_key(
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
public_link,
|
||||
server_id=least_loaded_cluster,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO connections (tg_id, trial)
|
||||
VALUES ($1, 1)
|
||||
ON CONFLICT (tg_id)
|
||||
DO UPDATE SET trial = 1
|
||||
""",
|
||||
tg_id,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def create_key_on_server(
|
||||
cluster_id: str,
|
||||
server_id: str,
|
||||
client_id: str,
|
||||
email: str,
|
||||
tg_id: int,
|
||||
expiry_timestamp: int,
|
||||
):
|
||||
"""Создает ключ на сервере в указанном кластере и возвращает результат."""
|
||||
|
||||
xui = AsyncApi(
|
||||
CLUSTERS[cluster_id][server_id]["API_URL"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
|
||||
response = await add_client(
|
||||
xui,
|
||||
client_id,
|
||||
email,
|
||||
tg_id,
|
||||
limit_ip=1,
|
||||
total_gb=0,
|
||||
expiry_time=expiry_timestamp,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -1,327 +0,0 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
from aiogram import Bot, Router, types
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from client import delete_client
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, CLUSTERS, DATABASE_URL, TOTAL_GB
|
||||
from database import delete_key, get_balance, update_balance, update_key_expiry
|
||||
from handlers.keys.key_utils import renew_key_in_cluster
|
||||
from handlers.texts import KEY_EXPIRY_10H, KEY_EXPIRY_24H, KEY_RENEWED, RENEWAL_PLANS
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
async def notify_expiring_keys(bot: Bot):
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
logger.info("Подключение к базе данных успешно.")
|
||||
|
||||
current_time = datetime.utcnow().timestamp() * 1000
|
||||
threshold_time_10h = (
|
||||
datetime.utcnow() + timedelta(hours=10)
|
||||
).timestamp() * 1000
|
||||
threshold_time_24h = (datetime.utcnow() + timedelta(days=1)).timestamp() * 1000
|
||||
|
||||
logger.info("Начало обработки уведомлений.")
|
||||
|
||||
await notify_10h_keys(bot, conn, current_time, threshold_time_10h)
|
||||
await asyncio.sleep(1)
|
||||
await notify_24h_keys(bot, conn, current_time, threshold_time_24h)
|
||||
await asyncio.sleep(1)
|
||||
await handle_expired_keys(bot, conn, current_time)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомлений: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
logger.info("Соединение с базой данных закрыто.")
|
||||
|
||||
|
||||
async def is_bot_blocked(bot: Bot, chat_id: int) -> bool:
|
||||
try:
|
||||
member = await bot.get_chat_member(chat_id, bot.id)
|
||||
blocked = member.status == "left"
|
||||
logger.info(
|
||||
f"Статус бота для пользователя {chat_id}: {'заблокирован' if blocked else 'активен'}"
|
||||
)
|
||||
return blocked
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось проверить статус бота для пользователя {chat_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def notify_10h_keys(
|
||||
bot: Bot, conn: asyncpg.Connection, current_time: float, threshold_time_10h: float
|
||||
):
|
||||
records = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, email, expiry_time, client_id, server_id FROM keys
|
||||
WHERE expiry_time <= $1 AND expiry_time > $2 AND notified = FALSE
|
||||
""",
|
||||
threshold_time_10h,
|
||||
current_time,
|
||||
)
|
||||
|
||||
logger.info(f"Найдено {len(records)} ключей для уведомления за 10 часов.")
|
||||
for record in records:
|
||||
tg_id = record["tg_id"]
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "Ключ истек"
|
||||
elif time_left.days > 0:
|
||||
days_left_message = f"{time_left.days}"
|
||||
else:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"{hours_left}"
|
||||
|
||||
message = KEY_EXPIRY_10H.format(
|
||||
email=email,
|
||||
expiry_date=expiry_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
days_left_message=days_left_message,
|
||||
)
|
||||
|
||||
if not await is_bot_blocked(bot, tg_id):
|
||||
try:
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Продлить VPN",
|
||||
callback_data=f'renew_key|{record["client_id"]}',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💳 Пополнить баланс",
|
||||
callback_data="pay",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="👤 Личный кабинет", callback_data="view_profile"
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"Уведомление отправлено пользователю {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка при отправке уведомления пользователю {tg_id}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE keys SET notified = TRUE WHERE client_id = $1",
|
||||
record["client_id"],
|
||||
)
|
||||
logger.info(f"Обновлено поле notified для клиента {record['client_id']}.")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def notify_24h_keys(
|
||||
bot: Bot, conn: asyncpg.Connection, current_time: float, threshold_time_24h: float
|
||||
):
|
||||
logger.info("Проверка истекших ключей...")
|
||||
|
||||
records_24h = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, email, expiry_time, client_id, server_id FROM keys
|
||||
WHERE expiry_time <= $1 AND expiry_time > $2 AND notified_24h = FALSE
|
||||
""",
|
||||
threshold_time_24h,
|
||||
current_time,
|
||||
)
|
||||
|
||||
logger.info(f"Найдено {len(records_24h)} ключей для уведомления за 24 часа.")
|
||||
for record in records_24h:
|
||||
tg_id = record["tg_id"]
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "Ключ истек"
|
||||
elif time_left.days > 0:
|
||||
days_left_message = f"{time_left.days}"
|
||||
else:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"{hours_left}"
|
||||
|
||||
message_24h = KEY_EXPIRY_24H.format(
|
||||
email=email,
|
||||
days_left_message=days_left_message,
|
||||
expiry_date=expiry_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
if not await is_bot_blocked(bot, tg_id):
|
||||
try:
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Продлить VPN",
|
||||
callback_data=f'renew_key|{record["client_id"]}',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💳 Пополнить баланс",
|
||||
callback_data="pay",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="👤 Личный кабинет", callback_data="view_profile"
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await bot.send_message(tg_id, message_24h, reply_markup=keyboard)
|
||||
logger.info(f"Уведомление за 24 часа отправлено пользователю {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка при отправке уведомления за 24 часа пользователю {tg_id}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE keys SET notified_24h = TRUE WHERE client_id = $1",
|
||||
record["client_id"],
|
||||
)
|
||||
logger.info(
|
||||
f"Обновлено поле notified_24h для клиента {record['client_id']}."
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: float):
|
||||
logger.info("Проверка истекших ключей...")
|
||||
|
||||
adjusted_current_time = current_time + (3 * 60 * 60 * 1000)
|
||||
expiring_keys = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, client_id, expiry_time, email FROM keys
|
||||
WHERE expiry_time <= $1
|
||||
""",
|
||||
adjusted_current_time,
|
||||
)
|
||||
logger.info(f"Найдено {len(expiring_keys)} истекающих ключей.")
|
||||
|
||||
async def process_key(record):
|
||||
tg_id = record["tg_id"]
|
||||
client_id = record["client_id"]
|
||||
email = record["email"]
|
||||
balance = await get_balance(tg_id)
|
||||
expiry_time = record["expiry_time"]
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
logger.info(
|
||||
f"Время истечения ключа: {expiry_time} (дата: {expiry_date}), Текущее время: {current_date}, Оставшееся время: {time_left}"
|
||||
)
|
||||
|
||||
message_expired = (
|
||||
f"❌ Ваша подписка {email} истекла и была удалена!\n\n"
|
||||
"🔍 Перейдите в профиль для создания новой подписки.\n"
|
||||
"💡 Не откладывайте подключение VPN!"
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="👤 Личный кабинет", callback_data="view_profile"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
if balance >= RENEWAL_PLANS["1"]["price"]:
|
||||
await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"])
|
||||
new_expiry_time = int(
|
||||
(datetime.utcnow() + timedelta(days=30)).timestamp() * 1000
|
||||
)
|
||||
await update_key_expiry(client_id, new_expiry_time)
|
||||
|
||||
for cluster_id in CLUSTERS:
|
||||
await renew_key_in_cluster(
|
||||
cluster_id, email, client_id, new_expiry_time, TOTAL_GB
|
||||
)
|
||||
logger.info(
|
||||
f"Ключ для пользователя {tg_id} успешно продлен в кластере {cluster_id}."
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET notified = FALSE, notified_24h = FALSE
|
||||
WHERE client_id = $1
|
||||
""",
|
||||
client_id,
|
||||
)
|
||||
logger.info(
|
||||
f"Флаги notified и notified_24 сброшены для клиента с ID {client_id}."
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
tg_id, text=KEY_RENEWED, reply_markup=keyboard
|
||||
)
|
||||
logger.info(
|
||||
f"Уведомление об успешном продлении отправлено клиенту {tg_id}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка при отправке уведомления клиенту {tg_id}: {e}"
|
||||
)
|
||||
|
||||
else:
|
||||
await safe_send_message(
|
||||
bot, tg_id, message_expired, reply_markup=keyboard
|
||||
)
|
||||
await delete_key(client_id)
|
||||
|
||||
for cluster_id, cluster in CLUSTERS.items():
|
||||
for server_id, server in cluster.items():
|
||||
xui = AsyncApi(
|
||||
server["API_URL"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
await delete_client(xui, email, client_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке ключа для клиента {tg_id}: {e}")
|
||||
|
||||
await asyncio.gather(*[process_key(record) for record in expiring_keys])
|
||||
|
||||
|
||||
async def safe_send_message(bot, tg_id, text, reply_markup=None):
|
||||
try:
|
||||
await bot.send_message(tg_id, text, reply_markup=reply_markup)
|
||||
except Exception as e:
|
||||
if "chat not found" in str(e):
|
||||
logger.warning(f"Чат для клиента {tg_id} не найден.")
|
||||
else:
|
||||
logger.error(f"Ошибка при отправке сообщения клиенту {tg_id}: {e}")
|
||||
@@ -0,0 +1,11 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .general_notifications import router as general_notifications_router
|
||||
from .special_notifications import router as special_notifications_router
|
||||
|
||||
|
||||
router = Router(name="notifications_main_router")
|
||||
|
||||
router.include_routers(general_notifications_router, special_notifications_router)
|
||||
@@ -0,0 +1,444 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiogram import Bot, Router
|
||||
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
NOTIFICATION_TIME,
|
||||
NOTIFY_DELETE_DELAY,
|
||||
NOTIFY_DELETE_KEY,
|
||||
NOTIFY_INACTIVE_TRAFFIC,
|
||||
NOTIFY_MAXPRICE,
|
||||
NOTIFY_RENEW,
|
||||
NOTIFY_RENEW_EXPIRED,
|
||||
RENEWAL_PRICES,
|
||||
TOTAL_GB,
|
||||
TRIAL_TIME_DISABLE,
|
||||
)
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notification_time,
|
||||
check_notifications_bulk,
|
||||
delete_key,
|
||||
delete_notification,
|
||||
get_all_keys,
|
||||
get_balance,
|
||||
get_last_notification_time,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
|
||||
from handlers.notifications.notify_kb import build_notification_expired_kb, build_notification_kb
|
||||
from handlers.texts import (
|
||||
KEY_DELETED_MSG,
|
||||
KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG,
|
||||
KEY_EXPIRED_DELAY_HOURS_MSG,
|
||||
KEY_EXPIRED_DELAY_MINUTES_MSG,
|
||||
KEY_EXPIRED_NO_DELAY_MSG,
|
||||
KEY_EXPIRY_10H,
|
||||
KEY_EXPIRY_24H,
|
||||
KEY_RENEWED,
|
||||
KEY_RENEWED_TEMP_MSG,
|
||||
)
|
||||
from handlers.utils import format_hours, format_minutes
|
||||
from logger import logger
|
||||
|
||||
from .notify_utils import send_messages_with_limit, send_notification
|
||||
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
notification_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def periodic_notifications(bot: Bot):
|
||||
"""
|
||||
Периодическая проверка и отправка уведомлений.
|
||||
Защищена от одновременного запуска с помощью asyncio.Lock.
|
||||
"""
|
||||
while True:
|
||||
if notification_lock.locked():
|
||||
logger.warning("Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
|
||||
await asyncio.sleep(NOTIFICATION_TIME)
|
||||
continue
|
||||
|
||||
async with notification_lock:
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
|
||||
|
||||
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
|
||||
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
|
||||
|
||||
logger.info("Запуск обработки уведомлений")
|
||||
|
||||
try:
|
||||
keys = await get_all_keys(session=conn)
|
||||
keys = [k for k in keys if not k["is_frozen"]]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ключей: {e}")
|
||||
keys = []
|
||||
|
||||
if not TRIAL_TIME_DISABLE:
|
||||
await notify_inactive_trial_users(bot, conn)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys)
|
||||
await asyncio.sleep(1)
|
||||
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
|
||||
await asyncio.sleep(1)
|
||||
await handle_expired_keys(bot, conn, current_time, keys)
|
||||
await asyncio.sleep(0.5)
|
||||
if NOTIFY_INACTIVE_TRAFFIC:
|
||||
await notify_users_no_traffic(bot, conn, current_time, keys)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.info("Завершена обработка уведомлений")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в periodic_notifications: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
await conn.close()
|
||||
logger.info("Соединение с базой данных закрыто.")
|
||||
|
||||
await asyncio.sleep(NOTIFICATION_TIME)
|
||||
|
||||
|
||||
async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_24h: int, keys: list):
|
||||
"""
|
||||
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
|
||||
"""
|
||||
logger.info("Начало проверки подписок, истекающих через 24 часа.")
|
||||
|
||||
expiring_keys = [
|
||||
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_24h
|
||||
]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expiring_keys]
|
||||
emails = [key.get("email", "") for key in expiring_keys]
|
||||
|
||||
users = await check_notifications_bulk("key_24h", 24, conn, tg_ids=tg_ids, emails=emails)
|
||||
messages = []
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
notification_id = f"{email}_key_24h"
|
||||
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
)
|
||||
|
||||
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
|
||||
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
|
||||
|
||||
notification_text = KEY_EXPIRY_24H.format(
|
||||
email=email,
|
||||
hours_left_formatted=hours_left_formatted,
|
||||
formatted_expiry_date=formatted_expiry_date,
|
||||
)
|
||||
|
||||
if NOTIFY_RENEW:
|
||||
await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text)
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": notification_text,
|
||||
"photo": "notify_24h.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 24 часа.")
|
||||
|
||||
logger.info("Обработка всех уведомлений за 24 часа завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_10h: int, keys: list):
|
||||
"""
|
||||
Отправляет уведомления пользователям о том, что их подписка истекает через 10 часов.
|
||||
"""
|
||||
logger.info("Начало проверки подписок, истекающих через 10 часов.")
|
||||
|
||||
expiring_keys = [
|
||||
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_10h
|
||||
]
|
||||
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expiring_keys]
|
||||
emails = [key.get("email", "") for key in expiring_keys]
|
||||
|
||||
users = await check_notifications_bulk("key_10h", 10, conn, tg_ids=tg_ids, emails=emails)
|
||||
messages = []
|
||||
|
||||
for key in expiring_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
notification_id = f"{email}_key_10h"
|
||||
|
||||
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
|
||||
if not can_notify:
|
||||
continue
|
||||
|
||||
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
expiry_timestamp = key.get("expiry_time")
|
||||
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
|
||||
hours_left_formatted = (
|
||||
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
|
||||
)
|
||||
|
||||
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
|
||||
formatted_expiry_date = expiry_datetime.strftime("%d %B %Y, %H:%M (МСК)")
|
||||
|
||||
notification_text = KEY_EXPIRY_10H.format(
|
||||
email=email,
|
||||
hours_left_formatted=hours_left_formatted,
|
||||
formatted_expiry_date=formatted_expiry_date,
|
||||
)
|
||||
|
||||
if NOTIFY_RENEW:
|
||||
try:
|
||||
await process_auto_renew_or_notify(
|
||||
bot, conn, key, notification_id, 1, "notify_10h.jpg", notification_text
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": notification_text,
|
||||
"photo": "notify_10h.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 10 часов.")
|
||||
|
||||
logger.info("Обработка всех уведомлений за 10 часов завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
|
||||
"""
|
||||
Обрабатывает истекшие ключи, проверяя продление или удаление.
|
||||
"""
|
||||
logger.info("Начало обработки истекших ключей.")
|
||||
|
||||
expired_keys = [key for key in keys if key.get("expiry_time") and key.get("expiry_time") < current_time]
|
||||
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
|
||||
|
||||
tg_ids = [key["tg_id"] for key in expired_keys]
|
||||
emails = [key.get("email", "") for key in expired_keys]
|
||||
|
||||
users = await check_notifications_bulk("key_expired", 0, conn, tg_ids=tg_ids, emails=emails)
|
||||
messages = []
|
||||
|
||||
for key in expired_keys:
|
||||
tg_id = key["tg_id"]
|
||||
email = key.get("email", "")
|
||||
client_id = key.get("client_id")
|
||||
server_id = key.get("server_id")
|
||||
notification_id = f"{email}_key_expired"
|
||||
|
||||
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
|
||||
|
||||
if NOTIFY_RENEW_EXPIRED:
|
||||
try:
|
||||
balance = await get_balance(tg_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения баланса для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
renewal_period_months = 1
|
||||
renewal_cost = RENEWAL_PRICES[str(renewal_period_months)]
|
||||
|
||||
if balance >= renewal_cost:
|
||||
try:
|
||||
await process_auto_renew_or_notify(
|
||||
bot, conn, key, notification_id, 1, "notify_expired.jpg", KEY_RENEWED_TEMP_MSG
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
if NOTIFY_DELETE_KEY:
|
||||
delete_immediately = NOTIFY_DELETE_DELAY == 0
|
||||
delete_after_delay = False
|
||||
|
||||
if last_notification_time is not None:
|
||||
delete_after_delay = (current_time - last_notification_time) / (1000 * 60) >= NOTIFY_DELETE_DELAY
|
||||
logger.info(
|
||||
f"Прошло минут={(current_time - last_notification_time) / (1000 * 60):.2f} "
|
||||
f"NOTIFY_DELETE_DELAY={NOTIFY_DELETE_DELAY}"
|
||||
)
|
||||
|
||||
if delete_immediately or delete_after_delay:
|
||||
try:
|
||||
await delete_key_from_cluster(server_id, email, client_id)
|
||||
await delete_key(client_id, conn)
|
||||
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": KEY_DELETED_MSG.format(email=email),
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
|
||||
if last_notification_time is None and any(u["tg_id"] == tg_id and u["email"] == email for u in users):
|
||||
keyboard = build_notification_kb(email)
|
||||
|
||||
if NOTIFY_DELETE_DELAY > 0:
|
||||
hours = NOTIFY_DELETE_DELAY // 60
|
||||
minutes = NOTIFY_DELETE_DELAY % 60
|
||||
|
||||
if hours > 0:
|
||||
if minutes > 0:
|
||||
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(
|
||||
email=email, hours_formatted=format_hours(hours), minutes_formatted=format_minutes(minutes)
|
||||
)
|
||||
else:
|
||||
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(
|
||||
email=email, hours_formatted=format_hours(hours)
|
||||
)
|
||||
else:
|
||||
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(
|
||||
email=email, minutes_formatted=format_minutes(minutes)
|
||||
)
|
||||
else:
|
||||
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
|
||||
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": delay_message,
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истекших ключах.")
|
||||
|
||||
logger.info("Обработка истекших ключей завершена.")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def process_auto_renew_or_notify(
|
||||
bot, conn, key: dict, notification_id: str, renewal_period_months: int, standard_photo: str, standard_caption: str
|
||||
):
|
||||
"""
|
||||
Если баланс пользователя позволяет, продлевает ключ на максимальный возможный срок и списывает средства;
|
||||
иначе отправляет стандартное уведомление.
|
||||
"""
|
||||
tg_id = key.get("tg_id")
|
||||
email = key.get("email", "")
|
||||
renew_notification_id = f"{email}_renew"
|
||||
|
||||
try:
|
||||
can_renew = await check_notification_time(tg_id, renew_notification_id, hours=24, session=conn)
|
||||
if not can_renew:
|
||||
logger.info(
|
||||
f"⏳ Подписка {email} уже продлевалась в течение последних 24 часов, повторное продление отменено."
|
||||
)
|
||||
return
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных для пользователя {tg_id}: {e}")
|
||||
return
|
||||
|
||||
if NOTIFY_MAXPRICE:
|
||||
renewal_period_months = max(
|
||||
(int(months) for months, price in RENEWAL_PRICES.items() if balance >= price), default=None
|
||||
)
|
||||
else:
|
||||
renewal_period_months = 1 if balance >= RENEWAL_PRICES["1"] else None
|
||||
|
||||
if renewal_period_months:
|
||||
renewal_period_months = int(renewal_period_months)
|
||||
renewal_cost = RENEWAL_PRICES[str(renewal_period_months)]
|
||||
client_id = key.get("client_id")
|
||||
server_id = key.get("server_id")
|
||||
current_expiry = key.get("expiry_time")
|
||||
new_expiry_time = current_expiry + renewal_period_months * 30 * 24 * 3600 * 1000
|
||||
|
||||
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, moscow_tz).strftime("%d %B %Y, %H:%M")
|
||||
total_gb = int(renewal_period_months * TOTAL_GB * 1024**3)
|
||||
|
||||
logger.info(
|
||||
f"[Автопродление] Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
|
||||
)
|
||||
|
||||
try:
|
||||
await renew_key_in_cluster(server_id, email, client_id, new_expiry_time, total_gb)
|
||||
await update_balance(tg_id, -renewal_cost, session=conn)
|
||||
await update_key_expiry(client_id, new_expiry_time, conn)
|
||||
|
||||
await add_notification(tg_id, renew_notification_id, session=conn)
|
||||
await delete_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
logger.info(
|
||||
f"✅ Ключ {client_id} продлён на {renewal_period_months} мес. для пользователя {tg_id}. Списано {renewal_cost}."
|
||||
)
|
||||
|
||||
renewed_message = KEY_RENEWED.format(
|
||||
email=email, months=renewal_period_months, expiry_date=formatted_expiry_date
|
||||
)
|
||||
|
||||
keyboard = build_notification_expired_kb()
|
||||
result = await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
|
||||
if result:
|
||||
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
|
||||
else:
|
||||
logger.warning(
|
||||
f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}."
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.error(f"❌ Ошибка форматирования сообщения KEY_RENEWED: отсутствует ключ {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
result = await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
|
||||
if result:
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
else:
|
||||
logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
@@ -0,0 +1,29 @@
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
from handlers.buttons import MAIN_MENU, RENEW_KEY
|
||||
|
||||
|
||||
def build_notification_kb(email: str) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Формирует inline-клавиатуру для уведомлений.
|
||||
Кнопки: "🔄 Продлить VPN" (callback_data содержит email) и "👤 Личный кабинет".
|
||||
"""
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=RENEW_KEY, callback_data=f"renew_key|{email}")
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def build_notification_expired_kb() -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Формирует inline-клавиатуру для уведомлений после удаления или продления.
|
||||
Кнопка: "👤 Личный кабинет"
|
||||
"""
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=MAIN_MENU, callback_data="profile")
|
||||
return builder.as_markup()
|
||||
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import aiofiles
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
|
||||
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def send_messages_with_limit(bot: Bot, messages: list[dict], messages_per_second: int = 25):
|
||||
"""
|
||||
Отправляет сообщения с ограничением по количеству сообщений в секунду.
|
||||
"""
|
||||
batch_size = messages_per_second
|
||||
for i in range(0, len(messages), batch_size):
|
||||
batch = messages[i : i + batch_size]
|
||||
tasks = []
|
||||
for msg in batch:
|
||||
tasks.append(send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке сообщений в батче: {e}")
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
|
||||
def rate_limited_send(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
while True:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except TelegramRetryAfter as e:
|
||||
retry_in = int(e.retry_after) + 1
|
||||
logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек.")
|
||||
await asyncio.sleep(retry_in)
|
||||
except TelegramForbiddenError:
|
||||
tg_id = kwargs.get("tg_id") or args[1]
|
||||
logger.warning(f"Пользователь {tg_id} заблокировал бота.")
|
||||
return False
|
||||
except Exception as e:
|
||||
tg_id = kwargs.get("tg_id") or args[1]
|
||||
logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
|
||||
return False
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def send_notification(
|
||||
bot: Bot,
|
||||
tg_id: int,
|
||||
image_filename: str | None,
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Отправляет уведомление пользователю.
|
||||
"""
|
||||
if image_filename is None:
|
||||
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
||||
|
||||
photo_path = os.path.join("img", image_filename)
|
||||
if os.path.isfile(photo_path):
|
||||
return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
|
||||
else:
|
||||
logger.warning(f"Файл с изображением не найден: {photo_path}")
|
||||
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
||||
|
||||
|
||||
@rate_limited_send
|
||||
async def _send_photo_notification(
|
||||
bot: Bot,
|
||||
tg_id: int,
|
||||
photo_path: str,
|
||||
image_filename: str,
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""Отправляет уведомление с изображением."""
|
||||
try:
|
||||
async with aiofiles.open(photo_path, "rb") as image_file:
|
||||
image_data = await image_file.read()
|
||||
buffered_photo = BufferedInputFile(image_data, filename=image_filename)
|
||||
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
|
||||
return True
|
||||
except TelegramForbiddenError:
|
||||
logger.error(f"Пользователь {tg_id} заблокировал бота")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
|
||||
return await _send_text_notification(bot, tg_id, caption, keyboard)
|
||||
|
||||
|
||||
@rate_limited_send
|
||||
async def _send_text_notification(
|
||||
bot: Bot,
|
||||
tg_id: int,
|
||||
caption: str,
|
||||
keyboard: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""Отправляет текстовое уведомление."""
|
||||
try:
|
||||
await bot.send_message(tg_id, caption, reply_markup=keyboard)
|
||||
return True
|
||||
except TelegramForbiddenError:
|
||||
logger.error(f"Пользователь {tg_id} заблокировал бота")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,182 @@
|
||||
import asyncio
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiogram import Bot, Router, types
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import NOTIFY_EXTRA_DAYS, NOTIFY_INACTIVE, NOTIFY_INACTIVE_TRAFFIC, SUPPORT_CHAT_URL, TRIAL_TIME
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notifications_bulk,
|
||||
create_blocked_user,
|
||||
)
|
||||
from handlers.buttons import MAIN_MENU
|
||||
from handlers.keys.key_utils import get_user_traffic
|
||||
from handlers.texts import TRIAL_INACTIVE_BONUS_MSG, TRIAL_INACTIVE_FIRST_MSG, ZERO_TRAFFIC_MSG
|
||||
from handlers.utils import format_days
|
||||
from logger import logger
|
||||
from handlers.utils import format_days
|
||||
|
||||
from .notify_utils import send_messages_with_limit, send_notification
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
"""
|
||||
Проверяет пользователей, не активировавших пробный период, и отправляет им напоминания.
|
||||
Первое уведомление — стандартное.
|
||||
Если прошло 24 часа и триал не активирован, отправляется уведомление с бонусом +2 дня.
|
||||
"""
|
||||
logger.info("Проверка пользователей, не активировавших пробный период...")
|
||||
|
||||
users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn)
|
||||
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
|
||||
|
||||
messages = []
|
||||
|
||||
for user in users:
|
||||
tg_id = user["tg_id"]
|
||||
username = user["username"]
|
||||
first_name = user["first_name"]
|
||||
last_name = user["last_name"]
|
||||
display_name = username or first_name or last_name or "Пользователь"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🚀 Активировать пробный период",
|
||||
callback_data="create_key",
|
||||
)
|
||||
)
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
trial_extended = user["last_notification_time"] is not None
|
||||
|
||||
if trial_extended:
|
||||
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
|
||||
message = TRIAL_INACTIVE_BONUS_MSG.format(
|
||||
display_name=display_name,
|
||||
extra_days_formatted=format_days(NOTIFY_EXTRA_DAYS),
|
||||
total_days_formatted=format_days(total_days),
|
||||
)
|
||||
await conn.execute("UPDATE users SET trial = -1 WHERE tg_id = $1", tg_id)
|
||||
else:
|
||||
message = TRIAL_INACTIVE_FIRST_MSG.format(
|
||||
display_name=display_name, trial_time_formatted=format_days(TRIAL_TIME)
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
|
||||
await add_notification(tg_id, "inactive_trial", session=conn)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
|
||||
|
||||
|
||||
async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
|
||||
"""
|
||||
Проверяет трафик пользователей, у которых ещё не отправлялось уведомление о нулевом трафике.
|
||||
Если трафик 0 ГБ и прошло более 2 часов с момента создания ключа, отправляет уведомление,
|
||||
но исключает пользователей, у которых подписка недавно продлилась.
|
||||
"""
|
||||
logger.info("Проверка пользователей с нулевым трафиком...")
|
||||
|
||||
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
|
||||
|
||||
for key in keys:
|
||||
tg_id = key.get("tg_id")
|
||||
email = key.get("email")
|
||||
created_at = key.get("created_at")
|
||||
client_id = key.get("client_id")
|
||||
expiry_time = key.get("expiry_time")
|
||||
notified = key.get("notified")
|
||||
|
||||
if created_at is None:
|
||||
logger.warning(f"Для {email} нет значения created_at. Пропускаем.")
|
||||
continue
|
||||
|
||||
if notified is True:
|
||||
continue
|
||||
|
||||
created_at_dt = pytz.utc.localize(datetime.fromtimestamp(created_at / 1000)).astimezone(moscow_tz)
|
||||
created_at_plus_2 = created_at_dt + timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
|
||||
|
||||
if expiry_time:
|
||||
expiry_dt = pytz.utc.localize(datetime.fromtimestamp(expiry_time / 1000)).astimezone(moscow_tz)
|
||||
renewal_threshold = expiry_dt - timedelta(days=30)
|
||||
renewal_recent = current_dt - renewal_threshold < timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
|
||||
|
||||
if renewal_recent:
|
||||
continue
|
||||
|
||||
if current_dt < created_at_plus_2:
|
||||
continue
|
||||
|
||||
try:
|
||||
traffic_data = await get_user_traffic(conn, tg_id, email)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения трафика для {email}: {e}")
|
||||
continue
|
||||
|
||||
if traffic_data.get("status") != "success":
|
||||
logger.warning(f"⚠ Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
|
||||
continue
|
||||
|
||||
total_traffic = sum(
|
||||
value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
|
||||
)
|
||||
logger.info(f"Ключ для {email}: общий трафик: {total_traffic} ГБ")
|
||||
|
||||
if total_traffic == 0:
|
||||
logger.info(f"⚠ У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
message = ZERO_TRAFFIC_MSG.format(email=email)
|
||||
|
||||
try:
|
||||
result = await send_notification(bot, tg_id, None, message, keyboard)
|
||||
await conn.execute(
|
||||
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
|
||||
)
|
||||
if result:
|
||||
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
else:
|
||||
try:
|
||||
await conn.execute(
|
||||
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
|
||||
)
|
||||
logger.info(f"Ключ для {email} имеет трафик. Обновлено notified = TRUE.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления notified для пользователя {tg_id}: {e}")
|
||||
|
||||
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
|
||||
+100
-33
@@ -1,79 +1,146 @@
|
||||
import os
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import CRYPTO_BOT_ENABLE, FREEKASSA_ENABLE, ROBOKASSA_ENABLE, STARS_ENABLE, YOOKASSA_ENABLE
|
||||
from database import get_trial
|
||||
from handlers.start import send_welcome_message
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
DONATIONS_ENABLE,
|
||||
ROBOKASSA_ENABLE,
|
||||
STARS_ENABLE,
|
||||
YOOKASSA_ENABLE,
|
||||
YOOMONEY_ENABLE,
|
||||
)
|
||||
from database import get_last_payments
|
||||
from handlers.buttons import (
|
||||
BALANCE_HISTORY,
|
||||
COUPON,
|
||||
CRYPTOBOT,
|
||||
MAIN_MENU,
|
||||
PAYMENT,
|
||||
ROBOKASSA,
|
||||
STARS,
|
||||
YOOKASSA,
|
||||
YOOMONEY,
|
||||
)
|
||||
from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
|
||||
|
||||
from .utils import edit_or_send_message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay")
|
||||
async def handle_pay(callback_query: CallbackQuery):
|
||||
await callback_query.message.delete()
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if YOOKASSA_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💳 ЮКасса: быстрый перевод",
|
||||
text=YOOKASSA,
|
||||
callback_data="pay_yookassa",
|
||||
)
|
||||
)
|
||||
if FREEKASSA_ENABLE:
|
||||
if YOOMONEY_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 FreeKassa: множество способов",
|
||||
callback_data="pay_freekassa",
|
||||
text=YOOMONEY,
|
||||
callback_data="pay_yoomoney",
|
||||
)
|
||||
)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 CryptoBot: криптовалюта",
|
||||
text=CRYPTOBOT,
|
||||
callback_data="pay_cryptobot",
|
||||
)
|
||||
)
|
||||
if STARS_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⭐ Оплата Звездами",
|
||||
text=STARS,
|
||||
callback_data="pay_stars",
|
||||
)
|
||||
)
|
||||
if ROBOKASSA_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⭐ RoboKassa",
|
||||
text=ROBOKASSA,
|
||||
callback_data="pay_robokassa",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🎟️ Активировать купон", callback_data="activate_coupon"
|
||||
)
|
||||
)
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬅️ Вернуться в профиль", callback_data="view_profile")
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
"💸 <b>Выберите удобный способ пополнения баланса:</b>\n\n"
|
||||
"• Быстро и безопасно\n"
|
||||
"• Поддержка разных платежных систем\n"
|
||||
"• Моментальное зачисление средств 🚀",
|
||||
parse_mode="HTML",
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=PAYMENT_METHODS_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(F.data == "balance")
|
||||
async def balance_handler(callback_query: CallbackQuery, session: Any):
|
||||
result = await session.fetchrow(
|
||||
"SELECT balance FROM users WHERE tg_id = $1",
|
||||
callback_query.from_user.id,
|
||||
)
|
||||
balance = result["balance"] if result else 0.0
|
||||
balance = int(balance)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
|
||||
builder.row(InlineKeyboardButton(text=COUPON, callback_data="activate_coupon"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
|
||||
image_path = os.path.join("img", "pic.jpg")
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=image_path,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "back_to_menu")
|
||||
async def handle_back_to_menu(callback_query: CallbackQuery):
|
||||
await callback_query.message.delete()
|
||||
trial_status = await get_trial(callback_query.from_user.id)
|
||||
await send_welcome_message(callback_query.from_user.id, trial_status)
|
||||
await callback_query.answer()
|
||||
@router.callback_query(F.data == "balance_history")
|
||||
async def balance_history_handler(callback_query: CallbackQuery, session: Any):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
|
||||
records = await get_last_payments(callback_query.from_user.id, session)
|
||||
|
||||
if records:
|
||||
history_text = BALANCE_HISTORY_HEADER
|
||||
for record in records:
|
||||
amount = record["amount"]
|
||||
payment_system = record["payment_system"]
|
||||
status = record["status"]
|
||||
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
history_text += (
|
||||
f"<b>Сумма:</b> {amount}₽\n"
|
||||
f"<b>Способ оплаты:</b> {payment_system}\n"
|
||||
f"<b>Статус:</b> {status}\n"
|
||||
f"<b>Дата:</b> {date}\n\n"
|
||||
)
|
||||
else:
|
||||
history_text = "❌ У вас пока нет операций с балансом."
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=history_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=None,
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
ROBOKASSA_ENABLE,
|
||||
STARS_ENABLE,
|
||||
YOOKASSA_ENABLE,
|
||||
YOOMONEY_ENABLE,
|
||||
)
|
||||
|
||||
from .cryprobot_pay import router as cryprobot_router
|
||||
from .gift import router as gift_router
|
||||
from .robokassa_pay import router as robokassa_router
|
||||
from .stars_pay import router as stars_router
|
||||
from .yookassa_pay import router as yookassa_router
|
||||
from .yoomoney_pay import router as yoomoney_router
|
||||
|
||||
router = Router(name="payments_main_router")
|
||||
|
||||
if YOOKASSA_ENABLE:
|
||||
router.include_router(yookassa_router)
|
||||
if YOOMONEY_ENABLE:
|
||||
router.include_router(yoomoney_router)
|
||||
if ROBOKASSA_ENABLE:
|
||||
router.include_router(robokassa_router)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
router.include_router(cryprobot_router)
|
||||
if STARS_ENABLE:
|
||||
router.include_router(stars_router)
|
||||
|
||||
router.include_router(gift_router)
|
||||
Binary file not shown.
@@ -1,272 +0,0 @@
|
||||
from aiocryptopay import AioCryptoPay, Networks
|
||||
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 aiohttp import web
|
||||
|
||||
from bot import bot
|
||||
from config import CRYPTO_BOT_ENABLE, CRYPTO_BOT_TOKEN, RUB_TO_USDT
|
||||
from database import add_connection, check_connection_exists, get_key_count, update_balance
|
||||
from handlers.texts import PAYMENT_OPTIONS
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
crypto = AioCryptoPay(token=CRYPTO_BOT_TOKEN, network=Networks.MAIN_NET)
|
||||
|
||||
|
||||
class ReplenishBalanceState(StatesGroup):
|
||||
choosing_amount_crypto = State()
|
||||
waiting_for_payment_confirmation_crypto = State()
|
||||
entering_custom_amount_crypto = State()
|
||||
|
||||
|
||||
async def send_message_with_deletion(
|
||||
chat_id, text, reply_markup=None, state=None, message_key="last_message_id"
|
||||
):
|
||||
if state:
|
||||
try:
|
||||
state_data = await state.get_data()
|
||||
previous_message_id = state_data.get(message_key)
|
||||
|
||||
if previous_message_id:
|
||||
await bot.delete_message(
|
||||
chat_id=chat_id, message_id=previous_message_id
|
||||
)
|
||||
|
||||
sent_message = await bot.send_message(
|
||||
chat_id=chat_id, text=text, reply_markup=reply_markup
|
||||
)
|
||||
await state.update_data({message_key: sent_message.message_id})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении/отправке сообщения: {e}")
|
||||
return None
|
||||
|
||||
return sent_message
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_cryptobot")
|
||||
async def process_callback_pay_cryptobot(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'crypto_{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'crypto_{PAYMENT_OPTIONS[i + 1]["callback_data"]}',
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'crypto_{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 Ввести свою сумму", callback_data="enter_custom_amount_crypto"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_profile"))
|
||||
|
||||
key_count = await get_key_count(tg_id)
|
||||
|
||||
if key_count == 0:
|
||||
exists = await check_connection_exists(tg_id)
|
||||
if not exists:
|
||||
await add_connection(tg_id, balance=0.0, trial=0)
|
||||
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text="Выберите сумму пополнения:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount_crypto)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("crypto_amount|"))
|
||||
async def process_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
data = callback_query.data.split("|", 1)
|
||||
|
||||
if len(data) != 2:
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
await callback_query.message.answer("Неверные данные для выбора суммы.")
|
||||
return
|
||||
|
||||
amount_str = data[1]
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
except ValueError:
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
await callback_query.message.answer("Некорректная сумма.")
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_crypto)
|
||||
|
||||
try:
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
invoice = await crypto.create_invoice(
|
||||
asset="USDT",
|
||||
amount=str(int(amount // RUB_TO_USDT)),
|
||||
description=f"Пополнения баланса на {amount} руб",
|
||||
payload=f"{callback_query.from_user.id}:{int(amount)}",
|
||||
)
|
||||
|
||||
if hasattr(invoice, "bot_invoice_url"):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="Пополнить", url=invoice.bot_invoice_url)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay"))
|
||||
await bot.send_message(
|
||||
chat_id=callback_query.from_user.id,
|
||||
text=f"Вы выбрали пополнение на {amount} рублей.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer("Ошибка при создании платежа.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании платежа: {e}")
|
||||
await callback_query.message.answer("Произошла ошибка при создании платежа.")
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
async def send_payment_success_notification(user_id: int, amount: float):
|
||||
try:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="Перейти в профиль", callback_data="view_profile")
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=f"Ваш баланс успешно пополнен на {amount} рублей. Спасибо за оплату!",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления пользователю {user_id}: {e}")
|
||||
|
||||
|
||||
async def cryptobot_webhook(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
logger.info(f"Получены данные вебхука: {data}")
|
||||
if data.get("update_type") == "invoice_paid":
|
||||
await process_crypto_payment(data["payload"])
|
||||
return web.Response(status=200)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Неподдерживаемый тип обновления: {data.get('update_type')}"
|
||||
)
|
||||
return web.Response(status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки вебхука: {e}")
|
||||
return web.Response(status=500)
|
||||
|
||||
|
||||
async def process_crypto_payment(payload):
|
||||
if payload["status"] == "paid":
|
||||
custom_payload = payload["payload"]
|
||||
user_id_str, amount_str = custom_payload.split(":")
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
amount = int(amount_str)
|
||||
logger.debug(f"Payment succeeded for user_id: {user_id}, amount: {amount}")
|
||||
await update_balance(user_id, amount)
|
||||
await send_payment_success_notification(user_id, amount)
|
||||
except ValueError as e:
|
||||
logger.error(f"Ошибка конвертации user_id или amount: {e}")
|
||||
else:
|
||||
logger.warning(f"Получен неоплаченный инвойс: {payload}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_amount_crypto")
|
||||
async def process_enter_custom_amount(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
await callback_query.message.edit_text(text="Введите сумму пополнения:")
|
||||
await state.set_state(ReplenishBalanceState.entering_custom_amount_crypto)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.message(ReplenishBalanceState.entering_custom_amount_crypto)
|
||||
async def process_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
if message.text.isdigit():
|
||||
amount = int(message.text)
|
||||
if amount // RUB_TO_USDT <= 0:
|
||||
await message.answer(
|
||||
f"Сумма должна быть больше {RUB_TO_USDT}. Пожалуйста, введите сумму еще раз:"
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(
|
||||
ReplenishBalanceState.waiting_for_payment_confirmation_crypto
|
||||
)
|
||||
try:
|
||||
invoice = await crypto.create_invoice(
|
||||
asset="USDT",
|
||||
amount=str(int(amount // RUB_TO_USDT)),
|
||||
description=f"Пополнения баланса на {amount} руб",
|
||||
payload=f"{message.from_user.id}:{amount}",
|
||||
)
|
||||
|
||||
if hasattr(invoice, "bot_invoice_url"):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="Пополнить", url=invoice.bot_invoice_url)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="pay"),
|
||||
)
|
||||
await message.message.edit_text(
|
||||
text=f"Вы выбрали пополнение на {amount} рублей.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await send_message_with_deletion(
|
||||
message.from_user.id, "Ошибка при создании платежа.", state=state
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании платежа: {e}")
|
||||
await message.answer("Произошла ошибка при создании платежа.")
|
||||
else:
|
||||
await message.answer("Некорректная сумма. Пожалуйста, введите сумму еще раз:")
|
||||
@@ -1,224 +0,0 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiohttp import web
|
||||
|
||||
from bot import bot
|
||||
from config import FREEKASSA_API_KEY, FREEKASSA_SHOP_ID
|
||||
from database import update_balance
|
||||
from handlers.texts import PAYMENT_OPTIONS
|
||||
|
||||
router = Router()
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
class ReplenishBalanceState(StatesGroup):
|
||||
choosing_amount_freekassa = State()
|
||||
waiting_for_payment_confirmation_freekassa = State()
|
||||
entering_custom_amount_freekassa = State()
|
||||
|
||||
|
||||
def generate_signature(params, api_key):
|
||||
sorted_params = {k: params[k] for k in sorted(params)}
|
||||
sign_string = "|".join(str(value) for value in sorted_params.values())
|
||||
return hmac.new(api_key.encode(), sign_string.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
async def create_payment(user_id, amount, email, ip):
|
||||
payment_id = str(uuid.uuid4())
|
||||
nonce = int(time.time() * 1000)
|
||||
params = {
|
||||
"shopId": FREEKASSA_SHOP_ID,
|
||||
"amount": amount,
|
||||
"currency": "RUB",
|
||||
"paymentId": payment_id,
|
||||
"email": email,
|
||||
"ip": ip,
|
||||
"i": 6,
|
||||
"nonce": nonce,
|
||||
}
|
||||
params["signature"] = generate_signature(params, FREEKASSA_API_KEY)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://api.freekassa.com/v1/orders/create", json=params
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
logging.debug(f"Ответ от FreeKassa при создании платежа: {response_data}")
|
||||
|
||||
if response_data.get("type") == "success":
|
||||
return response_data["location"]
|
||||
else:
|
||||
logging.error(f"Ошибка создания платежа: {response_data}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Ошибка запроса к FreeKassa: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def send_payment_success_notification(user_id, amount):
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=f"Ваш баланс успешно пополнен на {amount} рублей. Спасибо за оплату!",
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Ошибка при отправке уведомления пользователю {user_id}: {e}")
|
||||
|
||||
|
||||
async def freekassa_webhook(request):
|
||||
data = await request.json()
|
||||
logging.debug(f"Получен вебхук от FreeKassa: {data}")
|
||||
|
||||
logging.debug(f"Данные вебхука от FreeKassa: {data}")
|
||||
|
||||
if data["status"] == "completed":
|
||||
user_id = data["metadata"]["user_id"]
|
||||
amount = float(data["amount"])
|
||||
|
||||
await update_balance(user_id, amount)
|
||||
await send_payment_success_notification(user_id, amount)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@router.callback_query(lambda c: c.data == "pay_freekassa")
|
||||
async def process_callback_pay_freekassa(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'freekassa_{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'freekassa_{PAYMENT_OPTIONS[i + 1]["callback_data"]}',
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'freekassa_{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 Ввести свою сумму", callback_data="enter_custom_amount_freekassa"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_profile"))
|
||||
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text="Выберите сумму пополнения через FreeKassa:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount_freekassa)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("freekassa_amount|"))
|
||||
async def process_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
data = callback_query.data.split("|", 1)
|
||||
amount_str = data[1]
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
except ValueError:
|
||||
await bot.send_message(callback_query.from_user.id, "Некорректная сумма.")
|
||||
return
|
||||
|
||||
user_email = f"{callback_query.from_user.id}@solo.net"
|
||||
user_ip = callback_query.message.chat.id
|
||||
payment_url = await create_payment(
|
||||
callback_query.from_user.id, amount, user_email, user_ip
|
||||
)
|
||||
|
||||
if payment_url:
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"Оплатить {amount} рублей", url=payment_url
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="pay")],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
callback_query.from_user.id,
|
||||
f"Вы выбрали оплату на {amount} рублей. Перейдите по ссылке для завершения оплаты:",
|
||||
reply_markup=confirm_keyboard,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
callback_query.from_user.id,
|
||||
"Ошибка при создании платежа. Попробуйте позже.",
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_amount_freekassa")
|
||||
async def process_enter_custom_amount(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
await callback_query.message.edit_text(text="Введите сумму пополнения:")
|
||||
await state.set_state(ReplenishBalanceState.entering_custom_amount_freekassa)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.message(ReplenishBalanceState.entering_custom_amount_freekassa)
|
||||
async def process_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
if message.text.isdigit():
|
||||
amount = int(message.text)
|
||||
if amount <= 0:
|
||||
await message.answer(
|
||||
"Сумма должна быть больше нуля. Пожалуйста, введите сумму еще раз:"
|
||||
)
|
||||
return
|
||||
|
||||
user_email = f"{message.from_user.id}@solo.net"
|
||||
user_ip = message.chat.id
|
||||
payment_url = await create_payment(
|
||||
message.from_user.id, amount, user_email, user_ip
|
||||
)
|
||||
|
||||
if payment_url:
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton("Оплатить", url=payment_url)]]
|
||||
)
|
||||
await message.answer(
|
||||
f"Вы выбрали оплату на {amount} рублей. Перейдите по ссылке для завершения оплаты:",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await message.answer("Ошибка при создании платежа. Попробуйте позже.")
|
||||
|
||||
else:
|
||||
await message.answer("Пожалуйста, введите корректную сумму.")
|
||||
Binary file not shown.
+184
-166
@@ -1,18 +1,37 @@
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
ROBOKASSA_ENABLE,
|
||||
ROBOKASSA_LOGIN,
|
||||
ROBOKASSA_PASSWORD1,
|
||||
ROBOKASSA_PASSWORD2,
|
||||
ROBOKASSA_TEST_MODE,
|
||||
)
|
||||
from robokassa import HashAlgorithm, Robokassa
|
||||
|
||||
from bot import bot
|
||||
from config import ROBOKASSA_LOGIN, ROBOKASSA_PASSWORD1, ROBOKASSA_PASSWORD2, ROBOKASSA_TEST_MODE
|
||||
from database import add_connection, check_connection_exists, get_key_count, update_balance
|
||||
from handlers.texts import PAYMENT_OPTIONS
|
||||
from database import (
|
||||
add_user,
|
||||
check_user_exists,
|
||||
add_payment,
|
||||
get_key_count,
|
||||
get_temporary_data,
|
||||
update_balance,
|
||||
)
|
||||
from handlers.payments.utils import send_payment_success_notification
|
||||
from handlers.texts import PAYMENT_OPTIONS, ENTER_SUM, DEFAULT_PAYMENT_MESSAGE
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
from handlers.buttons import BACK, PAY_2
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -22,65 +41,34 @@ class ReplenishBalanceState(StatesGroup):
|
||||
waiting_for_payment_confirmation_robokassa = State()
|
||||
|
||||
|
||||
robokassa = Robokassa(
|
||||
merchant_login=ROBOKASSA_LOGIN,
|
||||
password1=ROBOKASSA_PASSWORD1,
|
||||
password2=ROBOKASSA_PASSWORD2,
|
||||
algorithm=HashAlgorithm.md5,
|
||||
is_test=ROBOKASSA_TEST_MODE,
|
||||
)
|
||||
|
||||
logger.info("Robokassa initialized with login: {}", ROBOKASSA_LOGIN)
|
||||
|
||||
|
||||
def generate_payment_link(amount, inv_id, description):
|
||||
"""Генерация ссылки на оплату."""
|
||||
logger.debug(
|
||||
f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}"
|
||||
if ROBOKASSA_ENABLE:
|
||||
robokassa = Robokassa(
|
||||
merchant_login=ROBOKASSA_LOGIN,
|
||||
password1=ROBOKASSA_PASSWORD1,
|
||||
password2=ROBOKASSA_PASSWORD2,
|
||||
algorithm=HashAlgorithm.md5,
|
||||
is_test=ROBOKASSA_TEST_MODE,
|
||||
)
|
||||
|
||||
logger.info("Robokassa initialized with login: {}", ROBOKASSA_LOGIN)
|
||||
|
||||
|
||||
def generate_payment_link(amount, inv_id, description, tg_id):
|
||||
"""Генерация ссылки на оплату."""
|
||||
logger.debug(f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}")
|
||||
payment_link = robokassa._payment.link.generate_by_script(
|
||||
out_sum=amount,
|
||||
inv_id=inv_id,
|
||||
description="description",
|
||||
description="пополнение баланса",
|
||||
id=f"{tg_id}",
|
||||
)
|
||||
logger.info(f"Generated payment link: {payment_link}")
|
||||
return payment_link
|
||||
|
||||
|
||||
async def send_message_with_deletion(
|
||||
chat_id, text, reply_markup=None, state=None, message_key="last_message_id"
|
||||
):
|
||||
if state:
|
||||
try:
|
||||
state_data = await state.get_data()
|
||||
previous_message_id = state_data.get(message_key)
|
||||
|
||||
if previous_message_id:
|
||||
logger.debug(
|
||||
f"Deleting previous message with ID: {previous_message_id}"
|
||||
)
|
||||
await bot.delete_message(
|
||||
chat_id=chat_id, message_id=previous_message_id
|
||||
)
|
||||
|
||||
sent_message = await bot.send_message(
|
||||
chat_id=chat_id, text=text, reply_markup=reply_markup
|
||||
)
|
||||
await state.update_data({message_key: sent_message.message_id})
|
||||
|
||||
logger.debug(f"Sent new message with ID: {sent_message.message_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении/отправке сообщения: {e}")
|
||||
return None
|
||||
|
||||
return sent_message
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_robokassa")
|
||||
async def process_callback_pay_robokassa(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
tg_id = callback_query.from_user.id
|
||||
async def process_callback_pay_robokassa(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} initiated Robokassa payment.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -103,54 +91,50 @@ async def process_callback_pay_robokassa(
|
||||
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 Ввести свою сумму", callback_data="enter_custom_amount_robokassa"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_profile"))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay"))
|
||||
|
||||
key_count = await get_key_count(tg_id)
|
||||
|
||||
if key_count == 0:
|
||||
exists = await check_connection_exists(tg_id)
|
||||
exists = await check_user_exists(tg_id)
|
||||
if not exists:
|
||||
await add_connection(tg_id, balance=0.0, trial=0)
|
||||
logger.info(f"Created new connection for user {tg_id} with balance 0.0.")
|
||||
from_user = callback_query.from_user
|
||||
await add_user(
|
||||
tg_id=from_user.id,
|
||||
username=from_user.username,
|
||||
first_name=from_user.first_name,
|
||||
last_name=from_user.last_name,
|
||||
language_code=from_user.language_code,
|
||||
is_bot=from_user.is_bot,
|
||||
session=session,
|
||||
)
|
||||
logger.info(f"[DB] Новый пользователь {tg_id} создан через Robokassa.")
|
||||
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
logger.debug(f"Deleted message with ID: {callback_query.message.message_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
await callback_query.message.delete()
|
||||
|
||||
new_message = await callback_query.message.answer(
|
||||
text="Выберите сумму пополнения:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.update_data(message_id=new_message.message_id, chat_id=new_message.chat.id)
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount_robokassa)
|
||||
logger.info(f"Displayed amount selection for user {tg_id}.")
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("robokassa_amount|"))
|
||||
async def process_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
logger.info(f"Получены данные callback_data: {callback_query.data}")
|
||||
|
||||
data = callback_query.data.split("|")
|
||||
if len(data) != 3 or data[1] != "amount":
|
||||
logger.error("Ошибка: callback_data не соответствует формату.")
|
||||
await send_message_with_deletion(
|
||||
chat_id=callback_query.from_user.id,
|
||||
text="Неверные данные для выбора суммы.",
|
||||
state=state,
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Ошибка: данные повреждены.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
force_text=True,
|
||||
)
|
||||
await callback_query.answer("Ошибка: данные повреждены.")
|
||||
return
|
||||
|
||||
amount_str = data[2]
|
||||
@@ -160,152 +144,186 @@ async def process_amount_selection(
|
||||
raise ValueError("Сумма должна быть положительным числом.")
|
||||
except ValueError as e:
|
||||
logger.error(f"Некорректное значение суммы: {amount_str}. Ошибка: {e}")
|
||||
await send_message_with_deletion(
|
||||
chat_id=callback_query.from_user.id,
|
||||
text="Некорректная сумма. Попробуйте снова.",
|
||||
state=state,
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Некорректная сумма.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
force_text=True,
|
||||
)
|
||||
await callback_query.answer("Некорректная сумма.")
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
logger.info(f"User {callback_query.from_user.id} selected amount: {amount}.")
|
||||
logger.info(f"User {callback_query.message.chat.id} selected amount: {amount}.")
|
||||
inv_id = 0
|
||||
|
||||
tg_id = callback_query.from_user.id
|
||||
payment_url = generate_payment_link(
|
||||
amount,
|
||||
tg_id,
|
||||
"Пополнение баланса",
|
||||
)
|
||||
tg_id = callback_query.message.chat.id
|
||||
payment_url = generate_payment_link(amount, inv_id, "Пополнение баланса", tg_id)
|
||||
|
||||
logger.info(f"Payment URL for user {callback_query.from_user.id}: {payment_url}")
|
||||
logger.info(f"Payment URL for user {callback_query.message.chat.id}: {payment_url}")
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Оплатить", url=payment_url)],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="pay_robokassa")],
|
||||
[InlineKeyboardButton(text=PAY_2, url=payment_url)],
|
||||
[InlineKeyboardButton(text=BACK, callback_data="pay_robokassa")],
|
||||
]
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"Вы выбрали пополнение на {amount} рублей. Для оплаты перейдите по ссылке ниже:",
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount),
|
||||
reply_markup=confirm_keyboard,
|
||||
force_text=True,
|
||||
)
|
||||
logger.info(f"Payment link sent to user {callback_query.from_user.id}.")
|
||||
await callback_query.answer()
|
||||
logger.info(f"Payment link sent to user {callback_query.message.chat.id}.")
|
||||
|
||||
|
||||
async def robokassa_webhook(request):
|
||||
"""Обработка webhook-уведомлений от Robokassa."""
|
||||
"""Обработка webhook-уведомлений от Robokassa с учетом shp_id."""
|
||||
try:
|
||||
params = await request.post()
|
||||
logger.debug(f"Received webhook params: {params}")
|
||||
|
||||
logger.info(f"Received webhook params: {params}")
|
||||
|
||||
amount = params.get("OutSum")
|
||||
inv_id = params.get("InvId")
|
||||
shp_id = params.get("shp_id")
|
||||
signature_value = params.get("SignatureValue")
|
||||
|
||||
logger.info(f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}")
|
||||
|
||||
if not check_payment_signature(params):
|
||||
logger.error("Неверная подпись или данные запроса.")
|
||||
return web.Response(status=400)
|
||||
|
||||
amount = params.get("OutSum")
|
||||
inv_id = params.get("InvId")
|
||||
|
||||
if not amount or not inv_id:
|
||||
if not amount or not inv_id or not shp_id:
|
||||
logger.error("Отсутствуют обязательные параметры.")
|
||||
return web.Response(status=400)
|
||||
|
||||
tg_id = inv_id
|
||||
tg_id = shp_id
|
||||
|
||||
logger.info(f"Processing payment for user {tg_id} with amount {amount}.")
|
||||
|
||||
await update_balance(int(tg_id), float(amount))
|
||||
await send_payment_success_notification(tg_id, float(amount))
|
||||
|
||||
await add_payment(int(tg_id), float(amount), "robokassa")
|
||||
|
||||
logger.info(f"Payment successful. Balance updated for user {tg_id}.")
|
||||
|
||||
return web.Response(text=f"OK{inv_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing webhook: {e}")
|
||||
return web.Response(status=500)
|
||||
|
||||
|
||||
def check_payment_signature(params):
|
||||
"""Проверка подписи запроса от Robokassa."""
|
||||
"""Проверка подписи запроса от Robokassa с учетом shp_id."""
|
||||
out_sum = params.get("OutSum")
|
||||
inv_id = params.get("InvId")
|
||||
signature_value = params.get("SignatureValue")
|
||||
shp_id = params.get("shp_id")
|
||||
|
||||
signature_string = f"{out_sum}:{inv_id}:{ROBOKASSA_PASSWORD2}"
|
||||
signature_string = f"{out_sum}:{inv_id}:{ROBOKASSA_PASSWORD2}:shp_id={shp_id}"
|
||||
|
||||
expected_signature = (
|
||||
hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper()
|
||||
)
|
||||
logger.info(f"Signature string before hashing: {signature_string}")
|
||||
|
||||
logger.debug(f"Expected signature: {expected_signature}")
|
||||
logger.debug(f"Received signature: {signature_value}")
|
||||
expected_signature = hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
return signature_value == expected_signature
|
||||
logger.info(f"Expected signature: {expected_signature}")
|
||||
logger.info(f"Received signature: {signature_value}")
|
||||
|
||||
|
||||
async def send_payment_success_notification(user_id: int, amount: float):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="Перейти в профиль", callback_data="view_profile")
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=f"Ваш баланс успешно пополнен на {amount} рублей. Спасибо за оплату!",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
logger.info(f"Sent payment success notification to user {user_id}.")
|
||||
return signature_value.upper() == expected_signature.upper()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_amount_robokassa")
|
||||
async def process_custom_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
tg_id = callback_query.from_user.id
|
||||
async def process_custom_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} chose to enter a custom amount.")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="Пожалуйста, введите сумму пополнения в рублях (например, 150):"
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
|
||||
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=ENTER_SUM,
|
||||
reply_markup=builder.as_markup(),
|
||||
force_text=True,
|
||||
)
|
||||
await state.set_state(
|
||||
ReplenishBalanceState.waiting_for_payment_confirmation_robokassa
|
||||
)
|
||||
await callback_query.answer()
|
||||
|
||||
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
|
||||
|
||||
|
||||
@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
|
||||
async def handle_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
tg_id = message.from_user.id
|
||||
logger.info(f"User {tg_id} entered custom amount: {message.text}")
|
||||
async def handle_custom_amount_input(
|
||||
message: types.Message | types.CallbackQuery, state: FSMContext = None, session: Any = None
|
||||
):
|
||||
if isinstance(message, types.CallbackQuery):
|
||||
tg_id = message.message.chat.id
|
||||
target_message = message.message
|
||||
else:
|
||||
tg_id = message.chat.id
|
||||
target_message = message
|
||||
|
||||
logger.info(f"User {tg_id} initiated payment through ROBOKASSA")
|
||||
inv_id = 0
|
||||
|
||||
try:
|
||||
amount = int(message.text)
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
user_data = await get_temporary_data(conn, tg_id)
|
||||
await conn.close()
|
||||
|
||||
if not user_data:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Данные для оплаты не найдены. Попробуйте снова.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
)
|
||||
return
|
||||
|
||||
state_type = user_data["state"]
|
||||
amount = user_data["data"].get("required_amount", 0)
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Сумма должна быть положительным числом.")
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
|
||||
payment_url = generate_payment_link(
|
||||
amount,
|
||||
tg_id,
|
||||
"Пополнение баланса",
|
||||
)
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Недостаточная сумма для пополнения.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = generate_payment_link(amount, inv_id, "Пополнение баланса", tg_id)
|
||||
logger.info(f"Generated payment link for user {tg_id}: {payment_url}")
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Оплатить", url=payment_url)],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="pay_robokassa")],
|
||||
]
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💳 Оплатить", url=payment_url))
|
||||
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_robokassa"))
|
||||
|
||||
await message.answer(
|
||||
text=f"Вы выбрали пополнение на {amount} рублей. Для оплаты перейдите по ссылке ниже:",
|
||||
reply_markup=confirm_keyboard,
|
||||
)
|
||||
await state.clear()
|
||||
except ValueError as e:
|
||||
logger.error(f"Некорректная сумма от пользователя {tg_id}: {e}")
|
||||
await message.answer(
|
||||
text="Введите корректную сумму в рублях (целое положительное число)."
|
||||
if state_type == "waiting_for_payment":
|
||||
message_text = (
|
||||
f"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
|
||||
)
|
||||
elif state_type == "waiting_for_renewal_payment":
|
||||
message_text = (
|
||||
f"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
|
||||
)
|
||||
else:
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Некорректное состояние данных. Попробуйте снова.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
)
|
||||
return
|
||||
|
||||
await edit_or_send_message(target_message=target_message, text=message_text, reply_markup=builder.as_markup())
|
||||
|
||||
if isinstance(state, FSMContext):
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании платежа для пользователя {tg_id}: {e}")
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text="Произошла ошибка при создании платежа. Попробуйте позже.",
|
||||
reply_markup=types.InlineKeyboardMarkup(),
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -1,243 +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, LabeledPrice, PreCheckoutQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import RUB_TO_XTR
|
||||
from database import add_connection, check_connection_exists, get_key_count, update_balance
|
||||
from handlers.texts import PAYMENT_OPTIONS
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class ReplenishBalanceState(StatesGroup):
|
||||
choosing_amount_stars = State()
|
||||
waiting_for_payment_confirmation_stars = State()
|
||||
entering_custom_amount_stars = State()
|
||||
|
||||
|
||||
async def send_message_with_deletion(
|
||||
chat_id, text, reply_markup=None, state=None, message_key="last_message_id"
|
||||
):
|
||||
if state:
|
||||
try:
|
||||
state_data = await state.get_data()
|
||||
previous_message_id = state_data.get(message_key)
|
||||
|
||||
if previous_message_id:
|
||||
await bot.delete_message(
|
||||
chat_id=chat_id, message_id=previous_message_id
|
||||
)
|
||||
|
||||
sent_message = await bot.send_message(
|
||||
chat_id=chat_id, text=text, reply_markup=reply_markup
|
||||
)
|
||||
await state.update_data({message_key: sent_message.message_id})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении/отправке сообщения: {e}")
|
||||
return None
|
||||
|
||||
return sent_message
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_stars")
|
||||
async def process_callback_pay_stars(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'stars_{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'stars_{PAYMENT_OPTIONS[i + 1]["callback_data"]}',
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'stars_{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 Ввести свою сумму", callback_data="enter_custom_amount_stars"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay"))
|
||||
|
||||
key_count = await get_key_count(tg_id)
|
||||
|
||||
if key_count == 0:
|
||||
exists = await check_connection_exists(tg_id)
|
||||
if not exists:
|
||||
await add_connection(tg_id, balance=0.0, trial=0)
|
||||
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text="Выберите сумму пополнения:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount_stars)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("stars_amount|"))
|
||||
async def process_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
data = callback_query.data.split("|", 1)
|
||||
|
||||
if len(data) != 2:
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
await callback_query.message.answer("Неверные данные для выбора суммы.")
|
||||
return
|
||||
|
||||
amount_str = data[1]
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
except ValueError:
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
|
||||
await callback_query.message.answer("Некорректная сумма.")
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation_stars)
|
||||
|
||||
try:
|
||||
await callback_query.message.delete()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="Пополнить", pay=True),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="pay"),
|
||||
)
|
||||
|
||||
await callback_query.message.answer_invoice(
|
||||
title=f"Вы выбрали пополнение на {amount} рублей.",
|
||||
description=f"Вы выбрали пополнение на {amount} рублей.",
|
||||
prices=[LabeledPrice(label="XTR", amount=int(amount // RUB_TO_XTR))],
|
||||
provider_token="",
|
||||
payload=f"{amount}_stars",
|
||||
currency="XTR",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании платежа: {e}")
|
||||
await callback_query.message.answer("Произошла ошибка при создании платежа.")
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
async def send_payment_success_notification(user_id: int, amount: float):
|
||||
try:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="Перейти в профиль", callback_data="view_profile")
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=f"Ваш баланс успешно пополнен на {amount} рублей. Спасибо за оплату!",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления пользователю {user_id}: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_amount_stars")
|
||||
async def process_enter_custom_amount(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
await callback_query.message.edit_text(text="Введите сумму пополнения:")
|
||||
await state.set_state(ReplenishBalanceState.entering_custom_amount_stars)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.message(ReplenishBalanceState.entering_custom_amount_stars)
|
||||
async def process_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
if message.text.isdigit():
|
||||
amount = int(message.text)
|
||||
if amount // RUB_TO_XTR <= 0:
|
||||
await message.answer(
|
||||
f"Сумма должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:"
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(
|
||||
ReplenishBalanceState.waiting_for_payment_confirmation_stars
|
||||
)
|
||||
try:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="Пополнить", pay=True),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="pay"),
|
||||
)
|
||||
await message.answer_invoice(
|
||||
title=f"Вы выбрали пополнение на {amount} рублей.",
|
||||
description=f"Вы выбрали пополнение на {amount} рублей.",
|
||||
prices=[LabeledPrice(label="XTR", amount=int(amount // RUB_TO_XTR))],
|
||||
provider_token="",
|
||||
payload=f"{amount}_stars",
|
||||
currency="XTR",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании платежа: {e}")
|
||||
await message.answer("Произошла ошибка при создании платежа.")
|
||||
else:
|
||||
await message.answer("Некорректная сумма. Пожалуйста, введите сумму еще раз:")
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def on_pre_checkout_query(pre_checkout_query: PreCheckoutQuery):
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def on_successful_payment(
|
||||
message: types.Message,
|
||||
):
|
||||
try:
|
||||
user_id = int(message.from_user.id)
|
||||
amount = float(message.successful_payment.invoice_payload.split("_")[0])
|
||||
logger.debug(f"Payment succeeded for user_id: {user_id}, amount: {amount}")
|
||||
await update_balance(user_id, amount)
|
||||
await send_payment_success_notification(user_id, amount)
|
||||
except ValueError as e:
|
||||
logger.error(f"Ошибка конвертации user_id или amount: {e}")
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,310 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiohttp import web
|
||||
from yookassa import Configuration, Payment
|
||||
|
||||
from bot import bot
|
||||
from config import YOOKASSA_ENABLE, YOOKASSA_SECRET_KEY, YOOKASSA_SHOP_ID
|
||||
from database import add_connection, check_connection_exists, get_key_count, update_balance
|
||||
from handlers.texts import PAYMENT_OPTIONS
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
if YOOKASSA_ENABLE:
|
||||
Configuration.account_id = YOOKASSA_SHOP_ID
|
||||
Configuration.secret_key = YOOKASSA_SECRET_KEY
|
||||
logger.debug(f"Account ID: {YOOKASSA_SHOP_ID}")
|
||||
logger.debug(f"Secret Key: {YOOKASSA_SECRET_KEY}")
|
||||
|
||||
|
||||
class ReplenishBalanceState(StatesGroup):
|
||||
choosing_amount_yookassa = State()
|
||||
waiting_for_payment_confirmation_yookassa = State()
|
||||
entering_custom_amount_yookassa = State()
|
||||
|
||||
|
||||
async def send_message_with_deletion(
|
||||
chat_id, text, reply_markup=None, state=None, message_key="last_message_id"
|
||||
):
|
||||
if state:
|
||||
try:
|
||||
state_data = await state.get_data()
|
||||
previous_message_id = state_data.get(message_key)
|
||||
|
||||
if previous_message_id:
|
||||
await bot.delete_message(
|
||||
chat_id=chat_id, message_id=previous_message_id
|
||||
)
|
||||
|
||||
sent_message = await bot.send_message(
|
||||
chat_id=chat_id, text=text, reply_markup=reply_markup
|
||||
)
|
||||
await state.update_data({message_key: sent_message.message_id})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении/отправке сообщения: {e}")
|
||||
return None
|
||||
|
||||
return sent_message
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_yookassa")
|
||||
async def process_callback_pay_yookassa(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'yookassa_{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'yookassa_{PAYMENT_OPTIONS[i + 1]["callback_data"]},',
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'yookassa_{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 Ввести свою сумму", callback_data="enter_custom_amount_yookassa"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_profile"))
|
||||
|
||||
key_count = await get_key_count(tg_id)
|
||||
|
||||
if key_count == 0:
|
||||
exists = await check_connection_exists(tg_id)
|
||||
if not exists:
|
||||
await add_connection(tg_id, balance=0.0, trial=0)
|
||||
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text="Выберите сумму пополнения:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount_yookassa)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("yookassa_amount|"))
|
||||
async def process_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
data = callback_query.data.split("|", 1)
|
||||
|
||||
if len(data) != 2:
|
||||
await send_message_with_deletion(
|
||||
callback_query.from_user.id,
|
||||
"Неверные данные для выбора суммы.",
|
||||
state=state,
|
||||
message_key="amount_error_message_id",
|
||||
)
|
||||
return
|
||||
|
||||
amount_str = data[1]
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
except ValueError:
|
||||
await send_message_with_deletion(
|
||||
callback_query.from_user.id,
|
||||
"Некорректная сумма.",
|
||||
state=state,
|
||||
message_key="amount_error_message_id",
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(
|
||||
ReplenishBalanceState.waiting_for_payment_confirmation_yookassa
|
||||
)
|
||||
|
||||
# state_data = await state.get_data()
|
||||
customer_name = callback_query.from_user.full_name
|
||||
customer_id = callback_query.from_user.id
|
||||
|
||||
customer_email = f"{customer_id}@solo.net"
|
||||
|
||||
payment = Payment.create(
|
||||
{
|
||||
"amount": {"value": str(amount), "currency": "RUB"},
|
||||
"confirmation": {"type": "redirect", "return_url": "https://pocomacho.ru/"},
|
||||
"capture": True,
|
||||
"description": "Пополнение баланса",
|
||||
"receipt": {
|
||||
"customer": {
|
||||
"full_name": customer_name,
|
||||
"email": customer_email,
|
||||
"phone": "79000000000",
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"description": "Пополнение баланса",
|
||||
"quantity": "1.00",
|
||||
"amount": {"value": str(amount), "currency": "RUB"},
|
||||
"vat_code": 6,
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": {"user_id": customer_id},
|
||||
},
|
||||
uuid.uuid4(),
|
||||
)
|
||||
|
||||
if payment["status"] == "pending":
|
||||
payment_url = payment["confirmation"]["confirmation_url"]
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Пополнить", url=payment_url)],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="pay")],
|
||||
]
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"Вы выбрали пополнение на {amount} рублей.",
|
||||
reply_markup=confirm_keyboard,
|
||||
)
|
||||
else:
|
||||
await send_message_with_deletion(
|
||||
callback_query.from_user.id, "Ошибка при создании платежа.", state=state
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
async def send_payment_success_notification(user_id: int, amount: float):
|
||||
try:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="Перейти в профиль", callback_data="view_profile")
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=f"Ваш баланс успешно пополнен на {amount} рублей. Спасибо за оплату!",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления пользователю {user_id}: {e}")
|
||||
|
||||
|
||||
async def yookassa_webhook(request):
|
||||
event = await request.json()
|
||||
logger.debug(f"Webhook event received: {event}")
|
||||
if event["event"] == "payment.succeeded":
|
||||
user_id_str = event["object"]["metadata"]["user_id"]
|
||||
amount_str = event["object"]["amount"]["value"]
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
amount = float(amount_str)
|
||||
logger.debug(f"Payment succeeded for user_id: {user_id}, amount: {amount}")
|
||||
await update_balance(user_id, amount)
|
||||
await send_payment_success_notification(user_id, amount)
|
||||
except ValueError as e:
|
||||
logger.error(f"Ошибка конвертации user_id или amount: {e}")
|
||||
return web.Response(status=400)
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_amount_yookassa")
|
||||
async def process_enter_custom_amount(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
await callback_query.message.edit_text(text="Введите сумму пополнения:")
|
||||
await state.set_state(ReplenishBalanceState.entering_custom_amount_yookassa)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.message(ReplenishBalanceState.entering_custom_amount_yookassa)
|
||||
async def process_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
if message.text.isdigit():
|
||||
amount = int(message.text)
|
||||
if amount <= 0:
|
||||
await message.answer(
|
||||
"Сумма должна быть больше нуля. Пожалуйста, введите сумму еще раз:"
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(
|
||||
ReplenishBalanceState.waiting_for_payment_confirmation_yookassa
|
||||
)
|
||||
|
||||
try:
|
||||
payment = Payment.create(
|
||||
{
|
||||
"amount": {"value": str(amount), "currency": "RUB"},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"return_url": "https://pocomacho.ru/",
|
||||
},
|
||||
"capture": True,
|
||||
"description": "Пополнение баланса",
|
||||
"receipt": {
|
||||
"customer": {
|
||||
"full_name": message.from_user.full_name,
|
||||
"email": f"{message.from_user.id}@solo.net",
|
||||
"phone": "79000000000",
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"description": "Пополнение баланса",
|
||||
"quantity": "1.00",
|
||||
"amount": {"value": str(amount), "currency": "RUB"},
|
||||
"vat_code": 6,
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": {"user_id": message.from_user.id},
|
||||
},
|
||||
uuid.uuid4(),
|
||||
)
|
||||
|
||||
if payment["status"] == "pending":
|
||||
payment_url = payment["confirmation"]["confirmation_url"]
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Пополнить", url=payment_url)],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="pay")],
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=f"Вы выбрали пополнение на {amount} рублей.",
|
||||
reply_markup=confirm_keyboard,
|
||||
)
|
||||
else:
|
||||
await message.answer("Ошибка при создании платежа.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании платежа: {e}")
|
||||
await message.answer("Произошла ошибка при создании платежа.")
|
||||
else:
|
||||
await message.answer("Некорректная сумма. Пожалуйста, введите сумму еще раз:")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user