Compare commits
1428 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd1e3f107 | |||
| 6c6ade4f7f | |||
| 30ad692bc2 | |||
| 24b3ea4881 | |||
| 560689d9ee | |||
| aa763403c6 | |||
| 96c0c3562d | |||
| e48eaf584c | |||
| 790cd6bab3 | |||
| 55ae2bd91c | |||
| d431931501 | |||
| 425d15c850 | |||
| 58affda938 | |||
| c4eb2a60de | |||
| 46dca8c095 | |||
| b752278398 | |||
| 6a659a2131 | |||
| 019397b0ea | |||
| 947a400909 | |||
| 7f52beb2a2 | |||
| 283d42af86 | |||
| d36d1e3041 | |||
| a83bc25bd0 | |||
| 3555ada3b5 | |||
| 1cb93d9688 | |||
| 6f9fc5e071 | |||
| 96d466328c | |||
| 846e7844db | |||
| 4e0b0837d9 | |||
| 8ba5cda095 | |||
| 7ac33bce28 | |||
| d8a0c166ac | |||
| c1858fe925 | |||
| dae8e4c34c | |||
| 3d7c219fd1 | |||
| 03781463e4 | |||
| dcd9074136 | |||
| 7fbd26c8c3 | |||
| 9677a55375 | |||
| eb51cda617 | |||
| 09bdde6f91 | |||
| 784b2ed996 | |||
| 9bee4a2365 | |||
| f8946b99f3 | |||
| a17cbf4448 | |||
| b0422e0c0f | |||
| 9d0bf8b3cf | |||
| 8c5e8ca019 | |||
| 0327adb8da | |||
| 5769fa63a5 | |||
| 1d09006b7c | |||
| e194ab9729 | |||
| 45a6173916 | |||
| b21baba006 | |||
| 45c1b4695c | |||
| 9b2b28423d | |||
| 9d00c2ddc6 | |||
| dc66676663 | |||
| 1d600e2c63 | |||
| 5ce177a7b5 | |||
| d181a333d7 | |||
| 3694cb4ed7 | |||
| 051048e0ae | |||
| 61ee6e42cf | |||
| 5bd618a799 | |||
| 7794a4cc7b | |||
| 6d01d32dc2 | |||
| be71def203 | |||
| 7a2de410bd | |||
| b019e8b32d | |||
| cb4f773eb1 | |||
| 29fe9f4943 | |||
| 63edfa852d | |||
| 66fabb6836 | |||
| f29ea49185 | |||
| 7620a3df92 | |||
| 577bcfdc88 | |||
| 33ec09edd0 | |||
| 7b3c463fa2 | |||
| 0e541af5e1 | |||
| e39822e732 | |||
| c7973e7b58 | |||
| 4941052d57 | |||
| 0022abc6e0 | |||
| acef77b0e8 | |||
| 4ddd580347 | |||
| 42cc4db077 | |||
| 8e3f976441 | |||
| 190d9ae13e | |||
| d6deec1a29 | |||
| e8dcbd09ff | |||
| 382f1eb6f7 | |||
| 2853410ebc | |||
| 2bde6a83ed | |||
| 83b1fa0f0f | |||
| 3a2a05d307 | |||
| 2460766e30 | |||
| 3eb01eaabc | |||
| 35bca4166d | |||
| f2db938fdc | |||
| ff0c68afc6 | |||
| 67babf7566 | |||
| f71788d2d4 | |||
| 5eebdcc85c | |||
| 4b89de9634 | |||
| b81d5b199b | |||
| a21559cd0d | |||
| 6cd3f87318 | |||
| f9e416341f | |||
| 8a2149d2f8 | |||
| e0f6e3b1ef | |||
| 8948f4d302 | |||
| d13ff6830b | |||
| b8aee33e88 | |||
| 17639f3a67 | |||
| cb77b5a21c | |||
| 012ba2049e | |||
| 6acfcc9966 | |||
| f6839c4184 | |||
| 1fbdcb7391 | |||
| 50a65d06d4 | |||
| 20855e5bfc | |||
| 2e4317e457 | |||
| 89dd41ef43 | |||
| 7674b0325b | |||
| 8a5141b58c | |||
| a819be0b5a | |||
| 7de4730822 | |||
| b4815b291e | |||
| 68da3d7d30 | |||
| ca13385dee | |||
| 9216128eb6 | |||
| 3d1fe09540 | |||
| 07b8e04afe | |||
| 01039a969a | |||
| a020db5bc6 | |||
| a91baf2acd | |||
| 1caa2b2ba2 | |||
| a7e9f732a2 | |||
| c8794722a2 | |||
| bbdb3e454e | |||
| fa693494d0 | |||
| 02c38a25ea | |||
| d5d60a8c7a | |||
| 864aaae017 | |||
| 413173270d | |||
| b8eb6606f1 | |||
| 83226c8f3d | |||
| 8a96cddb4f | |||
| e32f3191e4 | |||
| acb5c3c1d7 | |||
| cf13b49b3c | |||
| 2049765969 | |||
| 6b2af1f4a3 | |||
| 00d080cbfb | |||
| d0ce5438a8 | |||
| 05914c4e14 | |||
| a040d03df0 | |||
| a29e0d5fb4 | |||
| ea7697fd66 | |||
| ab86296256 | |||
| 9bb384788f | |||
| 6fc4eb033b | |||
| 8a8abf5b96 | |||
| 01e68bec68 | |||
| 7b6488d166 | |||
| 6c4e4b366a | |||
| 1defa938ba | |||
| efaac8ff76 | |||
| 9246312c68 | |||
| d4bbea616f | |||
| 1be8e1a60f | |||
| 0f1147a226 | |||
| dc7d0fa8d6 | |||
| 1fc4686490 | |||
| 8378020a3e | |||
| bce31ea9a4 | |||
| 5b4337a436 | |||
| ac0953a221 | |||
| bfdb17d653 | |||
| 3159e80897 | |||
| 35d3fb1916 | |||
| 4be51bbe51 | |||
| 2416bd6ad5 | |||
| b3697b1871 | |||
| 483fab8427 | |||
| 95ae04d553 | |||
| a6d8e77f9e | |||
| ae4814d1cb | |||
| fef4f81744 | |||
| 65057348e9 | |||
| 4d32e509e0 | |||
| 8e336e1f7b | |||
| ee7078705b | |||
| 48fe845ff4 | |||
| 99c882110a | |||
| a4f1323a81 | |||
| aad1bb8805 | |||
| d2eb1d35db | |||
| d6fa2a8f3c | |||
| 32b074efd4 | |||
| 787d801722 | |||
| 91c3f2766a | |||
| 911a20a4e3 | |||
| 8e04a0e2b5 | |||
| 6225463af1 | |||
| d4badacd4a | |||
| 63bf04bb70 | |||
| 2529c9bca2 | |||
| bc09d2f610 | |||
| d530d7a296 | |||
| bea0977a86 | |||
| 634b2782d8 | |||
| 0e28cfa326 | |||
| 3d2ca8a02d | |||
| e0e673e7bb | |||
| 0d4cea1e22 | |||
| b643f45c01 | |||
| b5b30a07b8 | |||
| 09ea9b0193 | |||
| cf76ac7893 | |||
| 83b370669e | |||
| 6735cf17cf | |||
| 422686dd2c | |||
| 147dc0343c | |||
| cdff9c4666 | |||
| 25db434e47 | |||
| 5a8aeadcd9 | |||
| bdceb9c3cf | |||
| f43e0daa69 | |||
| c24c668299 | |||
| 41df40eed3 | |||
| b1975cc3ff | |||
| 369e2c3c36 | |||
| e2ec4b05db | |||
| 7493b13348 | |||
| 0a65aa16cd | |||
| 64a4ece0fe | |||
| 71664cca3e | |||
| b21177f685 | |||
| cde6aa4c26 | |||
| 5264fc0d90 | |||
| 7aa2004f27 | |||
| 40a34d1e55 | |||
| 4204b459e2 | |||
| cbe80e9447 | |||
| 0cfd8d3c7b | |||
| ddfa61c56f | |||
| 7e340bc13a | |||
| f7dd3be27c | |||
| 3ff06ed9d1 | |||
| 08d6a26b23 | |||
| 37e472d78a | |||
| 8cc04e01e9 | |||
| c199d04b91 | |||
| 102d5d0a9c | |||
| 8e7b44811e | |||
| ab1be9943b | |||
| 61b702272b | |||
| 84bce0043d | |||
| f54528f2d5 | |||
| b66911f4f7 | |||
| 352c14331d | |||
| 7ea91f344c | |||
| d2d5b6c823 | |||
| 4405c2287a | |||
| 288a72f739 | |||
| 60391a8b89 | |||
| f2d4da9514 | |||
| 648ad24e6d | |||
| 5d28cd9930 | |||
| 43974d1685 | |||
| 39ccc7fb4a | |||
| d391a46b0e | |||
| 59dd638dd7 | |||
| 3062061270 | |||
| 5b0a64964e | |||
| 70e3517d3a | |||
| 08b64abb03 | |||
| 84a3179974 | |||
| fa56ad8791 | |||
| f584cb78f1 | |||
| ff3ac2b9f3 | |||
| deabcfba28 | |||
| 85775604a8 | |||
| bf9b99818d | |||
| 93a8c182bc | |||
| 7febaf9db2 | |||
| 21583279c6 | |||
| df11e09d3a | |||
| 0b2ebbcdd8 | |||
| 28be0954f9 | |||
| 44164b2948 | |||
| d9d4f171f6 | |||
| 4c73d1d0b6 | |||
| 0bf71971f9 | |||
| ff19fb8950 | |||
| 68a37fcf72 | |||
| 2caf16ccc6 | |||
| b38ee36698 | |||
| 3ee9c3de7b | |||
| 0fce289926 | |||
| b1e7f83d11 | |||
| ff28f82240 | |||
| d5082be405 | |||
| 3e830a32ea | |||
| c8a99ceed7 | |||
| 602ddd4013 | |||
| 33443955c7 | |||
| 8f8b6ac1fe | |||
| 8bb0795b1e | |||
| 0055560d3e | |||
| dcc209deea | |||
| a2cb7527f8 | |||
| 523c6ab69c | |||
| 91220b0b5f | |||
| f87dac8efa | |||
| 44b75be97a | |||
| 500ed4cdf9 | |||
| 498bd77076 | |||
| 29afb6e67f | |||
| 01d10b58dd | |||
| cf7aed9abf | |||
| 1b57531b28 | |||
| 375c096e82 | |||
| 89b0daae1d | |||
| 754c21a32f | |||
| 4928cdde43 | |||
| 057e5f6ab0 | |||
| 6d66d6f627 | |||
| be28e9bec4 | |||
| b923c4bcaa | |||
| 3a749c1864 | |||
| d16d5b7918 | |||
| 8484515722 | |||
| b9efff6e97 | |||
| 061557c438 | |||
| df09ccfbd3 | |||
| 16c7e140d0 | |||
| 967924a1ff | |||
| e028a25df2 | |||
| d3fcd11201 | |||
| 97b9056b0b | |||
| 00ba52672b | |||
| efe16b4269 | |||
| 5e94ad2189 | |||
| 53bb582c81 | |||
| 4c20ec19ab | |||
| a7042ea012 | |||
| f700285415 | |||
| ed1fc22f6b | |||
| c3c0c764fd | |||
| 746e810c58 | |||
| 2df5d21a9e | |||
| d664b99e1c | |||
| 74fe3b09a2 | |||
| efea0e8414 | |||
| 1dc4d0e04d | |||
| 84f06a96b3 | |||
| 4a7cc3306a | |||
| 5d576bc727 | |||
| 19a7a8e6b1 | |||
| 064f64afd0 | |||
| bfa5e5aab9 | |||
| f59bda57cf | |||
| 6887c265ad | |||
| 7db31ff5b5 | |||
| 6bee1bb85d | |||
| c604c5f6ff | |||
| ed5e6aa550 | |||
| 6b78d0f63e | |||
| fea1819ef8 | |||
| 45586277ab | |||
| 8ff3383246 | |||
| 7879dc5e43 | |||
| 392084e436 | |||
| 256e1cb778 | |||
| e131f77de3 | |||
| a60082e5e5 | |||
| 81bb8f8f4a | |||
| a9af75d9df | |||
| 30758848fa | |||
| 00c024bd2c | |||
| a69d28fb13 | |||
| ac818ccf34 | |||
| 533335bad9 | |||
| 37c99ae1fe | |||
| d9b232b87b | |||
| b8df611d0d | |||
| e0b5beed02 | |||
| f92cf97f63 | |||
| 9ae11bc525 | |||
| ef1426a938 | |||
| 456b02e2d9 | |||
| abb156a7b1 | |||
| ca8917a43c | |||
| 8ce75007ff | |||
| 76ced9aeeb | |||
| 42eeb67039 | |||
| c11be659b3 | |||
| 2a44d98015 | |||
| 885fa2fb1a | |||
| 8bcc2135ca | |||
| a26d2fb50b | |||
| ad3a8b3af3 | |||
| 918a450e63 | |||
| 596053c720 | |||
| abb83b43bc | |||
| 6f67a36a58 | |||
| 9cbc9bb3d5 | |||
| 1cbf3c5643 | |||
| 8f35d56e6a | |||
| b5773ba668 | |||
| fa5847ee64 | |||
| b561fbc9e5 | |||
| 305a0b6b95 | |||
| 12a16097cf | |||
| c8986bea40 | |||
| bcf01ecf6a | |||
| a334fbffb7 | |||
| fc8f55195f | |||
| dbd79cbc65 | |||
| 436686dd1b | |||
| 227e754f4a | |||
| 3d43358b86 | |||
| f05f32b92e | |||
| 7628b9aaa7 | |||
| 9ab8f1dee9 | |||
| 092ea034ec | |||
| c4e5261b31 | |||
| 2baf2b9abb | |||
| 87a6b05e54 | |||
| 0925dfdf80 | |||
| cf294fae0a | |||
| 12e77cd6bf | |||
| 867ae1b807 | |||
| db9f91b336 | |||
| df0792d4dd | |||
| 0e0569fe23 | |||
| daa37370e3 | |||
| b35c731ba5 | |||
| 06879089a1 | |||
| b96d530808 | |||
| 8f3c8f37c1 | |||
| e4c7a9cf15 | |||
| 935e2356db | |||
| 3b392a671d | |||
| 4ceb09d609 | |||
| 3f8ea7f8fd | |||
| 95525f2132 | |||
| d15b46dede | |||
| f2fc7581a3 | |||
| e96010d24d | |||
| 35fc39b9f2 | |||
| 7129625ab5 | |||
| e480356447 | |||
| 46ea2de300 | |||
| 2152ded3c4 | |||
| da0e8a2e3e | |||
| da540e8680 | |||
| a12ad52c7e | |||
| 38b5fa297b | |||
| d50475974c | |||
| ada891f2d9 | |||
| e7467e5434 | |||
| fa4a692574 | |||
| 9c60ef2c33 | |||
| 634633d12e | |||
| b3fd0c50ca | |||
| acb267668e | |||
| 344cffef60 | |||
| ef652d4518 | |||
| a66f9bbf28 | |||
| 3e5fbcf2bd | |||
| ba2de9e150 | |||
| b638e04629 | |||
| 45a5f012c2 | |||
| 1a66094800 | |||
| f7afe481bb | |||
| f04bf43962 | |||
| 3964ed1f3c | |||
| 98fa8eef5d | |||
| c7b47eef4d | |||
| 9270bb3bf5 | |||
| ad2f6f6f22 | |||
| 62749f9ffe | |||
| e623f9df3a | |||
| 2b79de7d82 | |||
| cdcc3b4c4a | |||
| e757569628 | |||
| 4f88cdf7db | |||
| 406c13aea2 | |||
| c95b1e4fad | |||
| 43c480fa33 | |||
| cb25bbb2ed | |||
| 2602a05fb2 | |||
| c31da6c2ae | |||
| 17d51ab286 | |||
| e576cb97ec | |||
| 217fed9c9c | |||
| 030f4efbe1 | |||
| 33b3828abc | |||
| f2876fd02c | |||
| ee9ba71f3c | |||
| 6a5d5d960f | |||
| 910409cdfb | |||
| d78b36b60e | |||
| ca2f83db88 | |||
| bf591f55ea | |||
| 99aa09349e | |||
| a391b635ed | |||
| 4f1ff34dce | |||
| 9a23a305b5 | |||
| 539e7e9066 | |||
| 6b1173a1b5 | |||
| 23f8e158ad | |||
| 578061be8c | |||
| 84816b84e7 | |||
| 0ad2f5bb8a | |||
| e415b4b2ee | |||
| 7d6ecaaa1b | |||
| 588461c53a | |||
| 25c23bd653 | |||
| 1a033de259 | |||
| 850f8d2b2e | |||
| 39374095eb | |||
| f4fe8913fa | |||
| 9a5203770b | |||
| 64b0f5161c | |||
| 8e12609746 | |||
| 703d09c556 | |||
| 7b238a32b4 | |||
| c72fa990fa | |||
| f8fc929cbf | |||
| 744aaeb5d6 | |||
| 515088ff45 | |||
| 96b64df989 | |||
| f51c22c67e | |||
| 010c412268 | |||
| 7d204684e1 | |||
| 08f47694ee | |||
| e61977a368 | |||
| 72ae83e6ef | |||
| 80aa0c2443 | |||
| 9e0eb3fd4a | |||
| 6fe4bad240 | |||
| 859089e91d | |||
| c4fa25321e | |||
| 96a6f26b1e | |||
| 3f0d125207 | |||
| cb5821fffc | |||
| f79420a9b0 | |||
| a7b4dc985f | |||
| 38562744ab | |||
| 3d413ced59 | |||
| 0002ce0a9a | |||
| 140d3580e9 | |||
| cf5cb406d0 | |||
| 8c3f3d56c8 | |||
| d33831dfe4 | |||
| b68f1256ca | |||
| 3d3b019e60 | |||
| f1a801d693 | |||
| de33e15ae0 | |||
| abd87c2a48 | |||
| f691e91560 | |||
| 7dafb0c29f | |||
| 5037d92e36 | |||
| d765e08017 | |||
| 4958613ad9 | |||
| 489667c847 | |||
| ccedfad51f | |||
| 841ac5cdb3 | |||
| a8b6202fe9 | |||
| 4708c67dcc | |||
| f705b508a1 | |||
| 6030fb08ec | |||
| a5b152e73a | |||
| 087a57fc11 | |||
| 95e44f99a2 | |||
| b8bb2acd64 | |||
| eb3e806e0f | |||
| 33911aca93 | |||
| 63c6e64cbe | |||
| 358c8aed3b | |||
| 4ac994454b | |||
| 5d6f080663 | |||
| 33f0e3b9ba | |||
| ef809de9a2 | |||
| 628c53513c | |||
| 876362cd80 | |||
| 1a9efd65a1 | |||
| 121918c33a | |||
| b46c3bd142 | |||
| 566b563626 | |||
| 397af1cc86 | |||
| bdfa2a3039 | |||
| 38001410b8 | |||
| c2693fc46a | |||
| 9db84af7a9 | |||
| 0172138084 | |||
| 0e913ec78b | |||
| 6a5d0181b0 | |||
| 7684ed382e | |||
| 0d47acf044 | |||
| 2d09b10f36 | |||
| 77c99ad33e | |||
| a41531944c | |||
| 9b2d9406d3 | |||
| 1776a5b424 | |||
| 419b921487 | |||
| c1f7404912 | |||
| 1c07f40d43 | |||
| d2e6a42eb8 | |||
| 5a7960bb14 | |||
| 27d48f68b4 | |||
| 9465c6cba3 | |||
| 3088be1dde | |||
| 845a8175fa | |||
| af27fb570c | |||
| 181bb137fb | |||
| 220646c7ac | |||
| 4513deb2a1 | |||
| e1001156b4 | |||
| ba2c534622 | |||
| 72f06a784e | |||
| 04e862b6bd | |||
| 7f4af89d60 | |||
| f68bb699eb | |||
| c1d19e2ade | |||
| e4c7e8b27f | |||
| c63c483b9b | |||
| 4b04696feb | |||
| 9ffbba7259 | |||
| cc0b927388 | |||
| cbcf2d55da | |||
| f7c70f60e1 | |||
| 7c96b0f71c | |||
| 2002032be3 | |||
| ed6ff293ea | |||
| 65e66353b2 | |||
| 4fa87804d9 | |||
| 17587a9b30 | |||
| 37c552c705 | |||
| 95c6e72f1b | |||
| 4fa49e6f2e | |||
| 3aaa3d0671 | |||
| 9c013f19c4 | |||
| c7b6929dec | |||
| 4dca932ec2 | |||
| 997fcf8ec7 | |||
| eb8c8ec4ce | |||
| f4ed61c4ca | |||
| 70d3434952 | |||
| 450d75227b | |||
| 6e61980fa9 | |||
| 777e243bcd | |||
| 46d9c25968 | |||
| e26223a7d8 | |||
| 2098165229 | |||
| c7e9d84585 | |||
| 8a92d5e5a9 | |||
| 8b8f187cb3 | |||
| 02e3faf194 | |||
| 7e8e05a0fc | |||
| c3ca12a8a5 | |||
| d82b24927b | |||
| 7d9d05b1e8 | |||
| cc96919672 | |||
| 1f0f5a4317 | |||
| cd9218ec09 | |||
| 7256a0d1ab | |||
| e2f5191844 | |||
| d964a2d53c | |||
| 7ace06834b | |||
| 0a789d3bd1 | |||
| 9c44c3ebd0 | |||
| 67650666fc | |||
| b5e343cfb0 | |||
| 68f72a88a4 | |||
| a225540c48 | |||
| 3fdc1fdeda | |||
| 0748e9eed3 | |||
| f420e07a24 | |||
| 885a56ecf4 | |||
| 4e4e80f3c4 | |||
| 4942ac4c5e | |||
| 20f83ac751 | |||
| 6b2b20cca2 | |||
| 9db392b410 | |||
| 444e87d70e | |||
| d8a6b6b08c | |||
| 2658c298f0 | |||
| 0ca4f9630a | |||
| 718c9aba92 | |||
| 078f1f8878 | |||
| c76eeea020 | |||
| f919368d0b | |||
| ccb442fe44 | |||
| d8c0dd862b | |||
| 1ed418b176 | |||
| 59efd837cd | |||
| 60d23bedde | |||
| d08f74179f | |||
| 70ad084bcd | |||
| 818ef1ccb0 | |||
| f6369f7b44 | |||
| 5f8295a588 | |||
| 5c51788dc3 | |||
| 24e6d57273 | |||
| be95d34122 | |||
| 359cdd106d | |||
| 0bc5b10a78 | |||
| efd4eda62a | |||
| d4c169e6bb | |||
| c12f3d443c | |||
| db7ecea006 | |||
| 2b4136faa7 | |||
| ca09ce8cf4 | |||
| 533087b333 | |||
| 80afcbb737 | |||
| 845faf037f | |||
| 1f160ced02 | |||
| 07e4c07d6e | |||
| 78ab84b021 | |||
| db5551df23 | |||
| ee75395c31 | |||
| 453bb75b7f | |||
| 88d2c8325d | |||
| 8fe9a3dbe2 | |||
| cf6c282b25 | |||
| 773e525a6a | |||
| a7d35d51b8 | |||
| 46eb93dabe | |||
| 43e63f864e | |||
| 8774b53570 | |||
| 2aaec2ca58 | |||
| 5efe36878d | |||
| b9861249b7 | |||
| 2c9b585f4b | |||
| dd5b36ff4a | |||
| 144a942a97 | |||
| ff661d84ee | |||
| ab1b8619f8 | |||
| 0f621bfe5a | |||
| c344f418c5 | |||
| 400cc5a32e | |||
| b6a383b5d8 | |||
| a8215ddec5 | |||
| 954c96ed16 | |||
| ed9a3613f5 | |||
| 29743351de | |||
| fd897464f5 | |||
| d9b0420202 | |||
| 527d907035 | |||
| 6c3270e6d7 | |||
| b7382eb7c3 | |||
| 0cd7d45338 | |||
| 23dcb9699c | |||
| 9603952b2a | |||
| 299eeee9d3 | |||
| 63c7e9c08b | |||
| 42131457bf | |||
| a7370fbe52 | |||
| 41f0a15cea | |||
| dd92f37fc1 | |||
| 46d4d699dd | |||
| 76b594363b | |||
| 107b18d642 | |||
| 9973491503 | |||
| 418081048e | |||
| f22838b99f | |||
| 9bdacea568 | |||
| 6bec0ed0c8 | |||
| 4363955443 | |||
| 47a272c7e8 | |||
| 07ce852917 | |||
| a6ada5c9e3 | |||
| 90c5b1d003 | |||
| 326aca31d7 | |||
| 4fd5ba863b | |||
| 4bd0b221e7 | |||
| f47511f26a | |||
| 2d409eaa1f | |||
| 9d42c4fb7d | |||
| d3ced8f7e2 | |||
| a5715b2ca8 | |||
| c18dd604d8 | |||
| c2656f4ee6 | |||
| 18641d46d3 | |||
| 7ef3a08215 | |||
| 55f1a3b220 | |||
| e2f313e6ea | |||
| 5f99478433 | |||
| 89f0a3663d | |||
| e53dab87e3 | |||
| 6d85c179bb | |||
| 30dafcef49 | |||
| 32d7a7394e | |||
| 3cb4f27433 | |||
| 50692566d5 | |||
| fbb2afd9e1 | |||
| 3de951b153 | |||
| 1b86d467b5 | |||
| e4743ced9b | |||
| 2351056d56 | |||
| 748f9a2393 | |||
| ada15065ea | |||
| 53f1831f59 | |||
| 860ebf18b6 | |||
| ae05ca08ea | |||
| 88e8a56526 | |||
| fb4d714441 | |||
| 2690f63a82 | |||
| ec9855a0a9 | |||
| 984ad8e448 | |||
| ca81b32502 | |||
| 70f190501b | |||
| 432efdbb7c | |||
| 83a821a591 | |||
| 14bdfd4a1e | |||
| 66ff2a38d3 | |||
| 9f7c9cad38 | |||
| 2132226b15 | |||
| 849465b2e1 | |||
| 402b31625b | |||
| 5fc24dbfae | |||
| 8ba65cc2fe | |||
| ee2bffe08e | |||
| c4b5db9002 | |||
| 1a78bdc194 | |||
| a3bb749a6a | |||
| 626f78b230 | |||
| 5d44285c0a | |||
| 2d8091ddcb | |||
| fa6a4698ab | |||
| f40454313f | |||
| 8a256b1bc2 | |||
| 71ac7ea464 | |||
| bbc9f93429 | |||
| 3ba9598b5e | |||
| ffa51986a8 | |||
| 7e20140a00 | |||
| 50d080f54e | |||
| 29829b1c8c | |||
| e84e707d2f | |||
| c12566ae53 | |||
| e6976b5262 | |||
| 81cfdcf7ac | |||
| 9c1ca5b748 | |||
| a0d14f97f4 | |||
| 8fdc70fdc3 | |||
| d93139a458 | |||
| b78dc12db9 | |||
| 2a02f589bf | |||
| bcfa4f8400 | |||
| 042dc900ef | |||
| 6004607bf6 | |||
| 40e4b1ce7f | |||
| a4c5a2295f | |||
| 3d9a75ff51 | |||
| 1216a4dd9d | |||
| 4bc1c402e9 | |||
| 42a758bd68 | |||
| 07a0d0ca43 | |||
| f53b04444b | |||
| 9877c00c92 | |||
| 14aa175193 | |||
| 562766a637 | |||
| 4616911189 | |||
| 1e14a0919f | |||
| e1f0bd152c | |||
| c10e34ad43 | |||
| 714f742560 | |||
| dc5e440fe2 | |||
| d53873dc7f | |||
| 10ab557d8d | |||
| 81147d42a9 | |||
| 8d5e2037d3 | |||
| 7f03e61b5f | |||
| 870ee2fb66 | |||
| a525079282 | |||
| 39aff2bc1b | |||
| b4dc9f1970 | |||
| 37ac3e7a81 | |||
| e3e6428d2a | |||
| 3e3385d1eb | |||
| 0375e74227 | |||
| b6bdeeefdd | |||
| 24b3660f7b | |||
| da2c30cd8d | |||
| 1fad95c0bb | |||
| 6a1b1a9a91 | |||
| 9abf0b6ebe | |||
| 34e90b335b | |||
| e84c82f29d | |||
| 1c929a1a70 | |||
| 1c8878e651 | |||
| 84645d84f7 | |||
| 0cc59501bc | |||
| ef6aaa58ea | |||
| 942ce48241 | |||
| 625ec43c34 | |||
| f184303415 | |||
| 84a17864cd | |||
| 1476247519 | |||
| 0ea95005ee | |||
| ba808f66da | |||
| e8472a502f | |||
| 7d552ab715 | |||
| 00de3a41df | |||
| 36f38187d9 | |||
| dd68b2b788 | |||
| 7a9d37c884 | |||
| 6a904d4be2 | |||
| b5b8408055 | |||
| 86a3228579 | |||
| 560993c0f3 | |||
| a3fe935149 | |||
| a75549aebf | |||
| be3fc7aa09 | |||
| 6cbfc6d74b | |||
| 7ff7a051d9 | |||
| 9a2f156158 | |||
| 38b058636c | |||
| 32523f5dea | |||
| ff70e38dbc | |||
| 8110f52bc3 | |||
| a5a0176433 | |||
| 2d960e3e30 | |||
| b5fa978023 | |||
| 9cc49e9f66 | |||
| dbf63e518f | |||
| 3e218b6e6d | |||
| 2f52f6b8de | |||
| 5d377cf885 | |||
| 62ecacc3cc | |||
| 4ab6d45e50 | |||
| d1bed5e41a | |||
| b907f884ba | |||
| 83a76c928d | |||
| 90d3f3aef2 | |||
| 263df606d2 | |||
| 2a38969e3d | |||
| 300feec18e | |||
| 7c56452eb4 | |||
| 548ec540f2 | |||
| bceba34cc3 | |||
| b07ce2be5c | |||
| 9472cf5e8c | |||
| 82f34e8c6e | |||
| 02b718dac5 | |||
| a65a23acb2 | |||
| 12523e7515 | |||
| d3497f924a | |||
| 75faa54b85 | |||
| 02a9ac9be5 | |||
| d22f9aaf62 | |||
| 68f5e2ab80 | |||
| 4e2b71cb85 | |||
| e229a7ee65 | |||
| abf1f53218 | |||
| 2859219f80 | |||
| e71b7f4533 | |||
| 5ba652fab6 | |||
| 0a3913a2aa | |||
| 118ef8a29f | |||
| b5f7e06605 | |||
| d258d27406 | |||
| 554fb28cdb | |||
| 808901c38b | |||
| 68508d8a86 | |||
| 0daf8d7691 | |||
| 5aeb2038c5 | |||
| d3a1060117 | |||
| 4d22e99076 | |||
| 879607982e | |||
| 5482b31fcc | |||
| f5be0e6807 | |||
| c3a60871c9 | |||
| 2628444866 | |||
| c1a7360c66 | |||
| b8c2bdc9e9 | |||
| 573735ffa9 | |||
| 9b3a9883b0 | |||
| 8cc54d376a | |||
| 26910dbbcc | |||
| b131fea7d1 | |||
| 84bc36d855 | |||
| ea5bd22c28 | |||
| e9f1064d73 | |||
| f3cba203dd | |||
| 0ff15850b7 | |||
| 383563c399 | |||
| 40bbb54f56 | |||
| a09eafb9c1 | |||
| 98b15d5c82 | |||
| b5cb547fe2 | |||
| a7af39fb34 | |||
| 02fa7de016 | |||
| b14ee9a6a4 | |||
| be2181c4cb | |||
| 6cd65812ac | |||
| b282fd1525 | |||
| 7c38e672e3 | |||
| c2685476b0 | |||
| a80e2176de | |||
| bf4de82442 | |||
| 22c736a41c | |||
| a1c5f9d987 | |||
| 1ca1df1403 | |||
| be3b554cde | |||
| efc8f04836 | |||
| 1cf7138430 | |||
| 64c8145f1a | |||
| dc62ad927d | |||
| 61188d8a97 | |||
| f92d5e996e | |||
| 817fb82476 | |||
| 9635f9464e | |||
| 33e0d58e4d | |||
| 5fb951328d | |||
| 5f5255d107 | |||
| 4bf7f83f1e | |||
| cce6d99a74 | |||
| 4060425ac4 | |||
| 5023bec388 | |||
| e0d90874bd | |||
| dd62ba1efd | |||
| ceb8191b43 | |||
| da2e55bb25 | |||
| 23f53dc5df | |||
| 8a3a990e39 | |||
| e3cd2b0b92 | |||
| 1777128590 | |||
| 04a5fe9d77 | |||
| 595a4eb3a3 | |||
| ede9259347 | |||
| d87556fd9f | |||
| 198ce23625 | |||
| 1be83b7b48 | |||
| 2b2b94936f | |||
| 84df8f727e | |||
| 84aaaf6254 | |||
| ce867efaea | |||
| 492d9f5170 | |||
| dcbbf7ba44 | |||
| 04e7f818c3 | |||
| 2713bdf08e | |||
| b2bfaf0e81 | |||
| 25a4be076b | |||
| cbd721a10f | |||
| 3ff8cb5e61 | |||
| 6f144f64b1 | |||
| 93200d1450 | |||
| 3f60533c50 | |||
| 41ab0abac8 | |||
| a5e4fb24ca | |||
| 6d2ce895e4 | |||
| f41a4197b6 | |||
| 200c728342 | |||
| d8571c4f3e | |||
| 233ceaf400 | |||
| e8bca6926a | |||
| 9e8aecae4d | |||
| e75b44695e | |||
| 18784b3ef7 | |||
| 98a241a301 | |||
| 70ee783a7a | |||
| 00cd0bb5b0 | |||
| 02b7365ba3 | |||
| a6da530848 | |||
| f6e6af5e42 | |||
| 6f834aeec0 | |||
| 8bfdde0d51 | |||
| 137ac3c58c | |||
| d7a0d9f7a5 | |||
| 9b477a700d | |||
| 77a9dcf5af | |||
| 1c1f7ade78 | |||
| 4aa593f8c6 | |||
| cefd39f7e9 | |||
| cfeb8fd9a3 | |||
| 241a053252 | |||
| b53726276c | |||
| 7316e4aa48 | |||
| 889629e3c1 | |||
| fbecf5bf7a | |||
| faae87212e | |||
| bfa355e956 | |||
| a2c0001f5a | |||
| 5fa2339018 | |||
| 9ba183b6a6 | |||
| 6d142a7f38 | |||
| 6f157e2923 | |||
| dccb070376 | |||
| f0b70c9424 | |||
| 9a847ec6b6 | |||
| 4a94d6a966 | |||
| ae654452e4 | |||
| 2e71fd89c9 | |||
| caf497ed28 | |||
| bb5dd7176b | |||
| ac83a65d3b | |||
| 147cbfa625 | |||
| 497775c382 | |||
| 3a9b8dc0f8 | |||
| 0fd3452dd6 | |||
| 1d5c277df0 | |||
| 6483efc099 | |||
| 149a9f599d | |||
| 1bd55c4d80 | |||
| ef9abe8f91 | |||
| 461b820ef1 | |||
| 0535353124 | |||
| 19804607ac | |||
| 272de0bd33 | |||
| b53a54a237 | |||
| e57c6d0ade | |||
| 18556f54d3 | |||
| a5c081887e | |||
| 80efd4ab86 | |||
| 3df541689b | |||
| 06c90dc3ca | |||
| d3599a6a0c | |||
| afab5f2515 | |||
| 37e7e74d28 | |||
| 25eff6929b | |||
| 72888ff02d | |||
| 9c572a2771 | |||
| 0f6d6256a2 | |||
| 2191e63803 | |||
| 43d75dc3ec | |||
| 59cae2fde6 | |||
| 4f4c9e5426 | |||
| df03f16a9d | |||
| 57db753218 | |||
| 5d4a2b51ef | |||
| 67c6185f8b | |||
| e3d18f7a1d | |||
| 4bcefaa7b8 | |||
| 9d9551c072 | |||
| 4e6bc5ae71 | |||
| c353c69180 | |||
| 9c0e489f3b | |||
| d4028a9d9f | |||
| 25960c180f | |||
| 926bd0cc55 | |||
| 0d714b4dff | |||
| f5ccc1f103 | |||
| 565e6d963b | |||
| c057d1e3d7 | |||
| 10c3151d9e | |||
| cbf88226b4 | |||
| 4ad53ca762 | |||
| a2ace90898 | |||
| 814e38ef59 | |||
| 871e1ae675 | |||
| 67c973124a | |||
| 42cfc3745d | |||
| ccb4dfa267 | |||
| 8a9606db97 | |||
| c3846a998a | |||
| 0bcaab3c50 | |||
| 6dc5150804 | |||
| 7bba18673a | |||
| de8853bd7c | |||
| 6e938b2693 | |||
| cb41f9263f | |||
| fe3f837a58 | |||
| 65374e38b3 | |||
| 24fd4ef842 | |||
| 5682971722 | |||
| c286c44041 | |||
| 1dc3013376 | |||
| ea61734f3a | |||
| ec521b84e7 | |||
| 62189f8642 | |||
| a8e1085884 | |||
| 2bd76d118a | |||
| b258bd95bd | |||
| ab566986c7 | |||
| 033230f73e | |||
| 063d77928c | |||
| ff71f4b637 | |||
| 88f4b2b2fa | |||
| b64bda64dc | |||
| dfeea949dd | |||
| 54d798f358 | |||
| 4546450587 | |||
| ec6b6e6b09 | |||
| cb5d4df4f0 | |||
| 1a060759f9 | |||
| 44fc2fd771 | |||
| 92dbf3269b | |||
| c6b4de05f3 | |||
| ae3c34c2fe | |||
| 34c19ff041 | |||
| 80c9857475 | |||
| c147a6c010 | |||
| 88a720543e | |||
| e780e3731d | |||
| 853cda603e | |||
| 5f8287f389 | |||
| 8fa7cbc060 | |||
| fe1b0fcac3 | |||
| 6f9a1a907d | |||
| 82bb72fc82 | |||
| 6812ff84b9 | |||
| 3096985466 | |||
| ce9e6d4379 | |||
| 1f376d4b5d | |||
| cfb830a694 | |||
| c072b4a932 | |||
| 9fe67eaeb2 | |||
| 389501239c | |||
| 9512813b58 | |||
| bebf636b4e | |||
| 1db1acf44d | |||
| be58a1c04e | |||
| fbdc98f8bd | |||
| ba79cf292f | |||
| 93b870a139 | |||
| cfa50739d8 | |||
| 86eedf1985 | |||
| 1bc7a1c143 | |||
| d6241d31f8 | |||
| a6465b0b0b | |||
| d1b28b669f | |||
| 68452974f1 | |||
| 09810d0b6c | |||
| 7358c4e3fa | |||
| 7380f4a15e | |||
| dbb11957ae | |||
| 151b72baff | |||
| d716e261b4 | |||
| 7ba21d058c | |||
| bbc64f685e | |||
| 2c28fda5e3 | |||
| 5a9ae02f61 | |||
| da6d971de4 | |||
| b4e7dfd099 | |||
| 39577b31bd | |||
| 6b039c1cb0 | |||
| 2cbff15837 | |||
| 921a36c94d | |||
| 14412871bf | |||
| afed43571d | |||
| 773dc11888 | |||
| e0f6dd4097 | |||
| 711ad05983 | |||
| 2d0a9f334b | |||
| 97996048ae | |||
| 66bd7f758e | |||
| c51158bad1 | |||
| ccf881c054 | |||
| 1f780a0ae8 | |||
| 17ac08af8f | |||
| 214488cf80 | |||
| c6c842adc9 | |||
| 4cb23dc2b1 | |||
| 7c9285061a | |||
| 0edfa973e4 | |||
| 0be20d514e | |||
| e414ae40d4 | |||
| b54b2a2dc2 | |||
| f545c0d7ad | |||
| 18142201bd | |||
| 50320ba868 | |||
| 12b05a7d0a | |||
| 7e19a5a1ff | |||
| 096e3a1629 | |||
| 51a5c4a65e | |||
| e5472b4c67 | |||
| ebf4c07142 | |||
| 4ab8aac38f | |||
| 8d32b1463a | |||
| ec09142445 | |||
| 1e3c713c95 | |||
| 1747170ec3 | |||
| 1e436189b7 | |||
| a247485a38 | |||
| b352cb2701 | |||
| b0b2c3f8d8 | |||
| aba98031f7 | |||
| 66ec241c48 | |||
| 2ee80b41e0 | |||
| c1aa08b266 | |||
| 7b7bdc3d88 | |||
| 71588fda63 | |||
| d4b1b20016 | |||
| 952face2ae | |||
| fa8c7bd460 | |||
| de8c506618 | |||
| 18a7d179f8 | |||
| 56c6e97874 | |||
| b574c01264 | |||
| 6d64ba8ffd | |||
| 6d0a85b7be | |||
| 33d5da9530 | |||
| 2a92004fbf | |||
| 97ec8594d9 | |||
| 713a2cd924 | |||
| 0d4fd3d6e9 | |||
| 7cc9d8dc18 | |||
| f896d59609 | |||
| 707858900a | |||
| de0b361062 | |||
| 111b038fec | |||
| a2f90d5de0 | |||
| 084363b3d6 | |||
| 9c0dfa7aa3 | |||
| c4bf18cf0f | |||
| 9663eb4931 | |||
| da846df924 | |||
| a5bb663f9e | |||
| b234905860 | |||
| f658cfcd03 | |||
| 56297cde91 | |||
| f51c74d09e | |||
| b06da932cd | |||
| f869b73bf5 | |||
| 4f40cb8862 | |||
| 161e9b575c | |||
| 42ccd3f3c1 | |||
| 5dcb37032e | |||
| e42bf2de73 | |||
| 4225404673 | |||
| cbfa388568 | |||
| d471a84125 | |||
| 2cc18422fd | |||
| 4ba60dab16 | |||
| 588e4acbd3 | |||
| 3a67bbe298 | |||
| eb730ecd6e | |||
| 112da2c873 | |||
| 37ff3c0d17 | |||
| 1451bfac12 | |||
| c445e24ff6 | |||
| bb5af66656 | |||
| 85e8b7fc24 | |||
| c4802fe442 | |||
| 91ee1d96b3 | |||
| 19fe40b172 | |||
| 355ac7a0fa | |||
| 361a8a6297 | |||
| 0b68aa85c7 | |||
| 3105b811d4 | |||
| b4d857eb88 | |||
| b438b6df61 | |||
| 15bda0560a | |||
| 3433ea6aef | |||
| 745ebf64ef | |||
| 80ff06ef24 | |||
| f270ac9f9d | |||
| 7d5d89302b | |||
| 155482779e | |||
| d1bacf140c | |||
| 55a38a5e05 | |||
| f21d451c13 | |||
| 29c3e00c27 | |||
| 44d249ad5b | |||
| 4333412169 | |||
| 50c7eaa29a | |||
| b2e168dbec | |||
| a1d4e9c7b3 | |||
| 82d1b48314 | |||
| eaffaa11b7 | |||
| 2c13995625 | |||
| 7b0892e379 | |||
| 483ccab3f7 | |||
| 7f62af6955 | |||
| 857dfc7e27 | |||
| f47ca40974 | |||
| 0256203f44 | |||
| 5bda94794e | |||
| 0d218980f7 | |||
| 73d8c30d9c | |||
| 7abafbeaa1 | |||
| 2a7728a76c | |||
| d266997720 | |||
| d6fb26d425 | |||
| 1eb3658ce5 | |||
| badf679bc3 | |||
| 1e542a0aa1 | |||
| cdbeca245f | |||
| 5a75626625 | |||
| 17e3985671 | |||
| 5a5f151b38 | |||
| 97c8ddf4e1 | |||
| df7414b670 | |||
| 8dfe2f2934 | |||
| 3f3968dda2 | |||
| 3930564f9b | |||
| 90edc1dc49 | |||
| c9f7906e10 | |||
| 1758eabe0e | |||
| 7ec73c9bc7 | |||
| 1b0ad8f8b6 | |||
| 555f76a4ec | |||
| bfb9c5d09c | |||
| 9a72a7e3e7 | |||
| b48c3f1235 | |||
| 85f59566a6 | |||
| 1c3f2c7500 | |||
| 47ca096e48 | |||
| a9da2a0e63 | |||
| aaea98049e | |||
| 93668da99f | |||
| 53c7df0b8e | |||
| 86e8ac39c3 | |||
| a9a10e3e90 | |||
| 1de81a6aa4 | |||
| 540a0e35a1 | |||
| f0ae702933 | |||
| 19be4348aa | |||
| 84ed1fc5ca | |||
| de38f8219c | |||
| 12061f0752 | |||
| a5174f10d5 | |||
| 69eeaf6c46 | |||
| ac25e1b4c6 | |||
| 51ba5b980c | |||
| c3f62a4ff6 | |||
| 9fb6d7ecf6 | |||
| d297e9b6d1 | |||
| 6d613cab85 | |||
| a0b6aa13a3 |
+78
-2
@@ -8,11 +8,17 @@ ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
# Автоматические отчеты
|
||||
ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
|
||||
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
@@ -83,8 +89,13 @@ MAX_DEVICES_LIMIT=15
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# Плюсовать дни триала при покупке платной подписке
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID=false
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
# Сбрасывать трафик при каждой оплате подписки
|
||||
RESET_TRAFFIC_ON_PAYMENT=false
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
@@ -136,6 +147,7 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
DEFAULT_AUTOPAY_ENABLED=true
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
@@ -150,6 +162,7 @@ TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_HOST=0.0.0.0
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
@@ -200,6 +213,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
@@ -220,7 +234,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=true
|
||||
CRYPTOBOT_ENABLED=false
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
@@ -231,6 +245,35 @@ CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# MULENPAY
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
MULENPAY_SECRET_KEY=
|
||||
MULENPAY_SHOP_ID=<ID магазина>
|
||||
# необязательно, есть дефолтные значения
|
||||
MULENPAY_BASE_URL=https://mulenpay.ru/api
|
||||
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
|
||||
MULENPAY_DESCRIPTION="Пополнение баланса"
|
||||
MULENPAY_LANGUAGE=ru
|
||||
MULENPAY_VAT_CODE=0
|
||||
MULENPAY_PAYMENT_SUBJECT=4
|
||||
MULENPAY_PAYMENT_MODE=4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
PAL24_API_TOKEN=
|
||||
PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
PAL24_REQUEST_TIMEOUT=30
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
@@ -245,10 +288,24 @@ HIDE_SUBSCRIPTION_LINK=false
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
|
||||
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
|
||||
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
|
||||
|
||||
# Параметры режима happ_cryptolink
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
|
||||
HAPP_DOWNLOAD_LINK_IOS=
|
||||
HAPP_DOWNLOAD_LINK_ANDROID=
|
||||
HAPP_DOWNLOAD_LINK_MACOS=
|
||||
HAPP_DOWNLOAD_LINK_WINDOWS=
|
||||
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
|
||||
|
||||
# Пропустить принятие правил использования бота
|
||||
SKIP_RULES_ACCEPT=false
|
||||
@@ -266,6 +323,23 @@ NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
MONITORING_LOGS_RETENTION_DAYS=30
|
||||
NOTIFICATION_CACHE_HOURS=24
|
||||
|
||||
# ===== СТАТУС СЕРВЕРОВ =====
|
||||
# Режимы: disabled, external_link, external_link_miniapp, xray
|
||||
SERVER_STATUS_MODE=disabled
|
||||
# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp)
|
||||
SERVER_STATUS_EXTERNAL_URL=
|
||||
# URL метрик XrayChecker (для режима xray)
|
||||
SERVER_STATUS_METRICS_URL=
|
||||
# Данные Basic Auth (опционально)
|
||||
SERVER_STATUS_METRICS_USERNAME=
|
||||
SERVER_STATUS_METRICS_PASSWORD=
|
||||
# Проверять SSL сертификат при запросе метрик
|
||||
SERVER_STATUS_METRICS_VERIFY_SSL=true
|
||||
# Таймаут запроса к метрикам (в секундах)
|
||||
SERVER_STATUS_REQUEST_TIMEOUT=10
|
||||
# Количество серверов на странице в режиме интеграции
|
||||
SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
@@ -276,6 +350,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.7-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.7-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.7-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.7-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.7-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.7-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!install_bot.sh
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
!docs/
|
||||
!docs/**
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.3.7"
|
||||
ARG VERSION="v2.4.7"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8081 8082 8083
|
||||
EXPOSE 8080 8081 8082
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
|
||||
+216
-146
@@ -1,163 +1,233 @@
|
||||
{
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"ru": "App Store [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"ru": "App Store [RU]"
|
||||
"config": {},
|
||||
"platforms": {
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"fa": "App Store [EU]",
|
||||
"ru": "App Store [EU]",
|
||||
"zh": "App Store [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"fa": "App Store [RU]",
|
||||
"ru": "App Store [RU]",
|
||||
"zh": "App Store [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"ru": "Скачать APK"
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"fa": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Google Play"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
|
||||
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"fa": "برنامه را باز کنید و به سرور متصل شوید",
|
||||
"ru": "Откройте приложение и подключитесь к серверу",
|
||||
"zh": "打开应用并连接到服务器"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Откройте приложение и подключитесь к серверу"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pc": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
|
||||
"buttonText": {
|
||||
"en": "Mac Os",
|
||||
"ru": "Mac Os"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"ru": "Windows"
|
||||
],
|
||||
"macos": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"fa": "App Store [EU]",
|
||||
"ru": "App Store [EU]",
|
||||
"zh": "App Store [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"fa": "App Store [RU]",
|
||||
"ru": "App Store [RU]",
|
||||
"zh": "App Store [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server",
|
||||
"ru": "Выберете локацию, включить VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tv": [
|
||||
{
|
||||
"id": "vpn4tv",
|
||||
"name": "VPN4TV",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
|
||||
"buttonText": {
|
||||
"en": "Download",
|
||||
"ru": "Скачать"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Download and install Happ.",
|
||||
"ru": "Скачайте и установите Happ"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server",
|
||||
"ru": "Выберете локацию, включить VPN"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"androidTV": [
|
||||
{
|
||||
"id": "vpn4tv",
|
||||
"name": "VPN4TV",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linux": [],
|
||||
"appleTV": []
|
||||
}
|
||||
}
|
||||
+22
-2
@@ -15,8 +15,8 @@ from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, common
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
)
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
@@ -33,10 +33,19 @@ from app.handlers.admin import (
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
promo_offers as admin_promo_offers,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates,
|
||||
backup as admin_backup,
|
||||
system_logs as admin_system_logs,
|
||||
welcome_text as admin_welcome_text,
|
||||
tickets as admin_tickets,
|
||||
reports as admin_reports,
|
||||
bot_configuration as admin_bot_configuration,
|
||||
pricing as admin_pricing,
|
||||
privacy_policy as admin_privacy_policy,
|
||||
public_offer as admin_public_offer,
|
||||
faq as admin_faq,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -117,6 +126,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
promocode.register_handlers(dp)
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
server_status.register_handlers(dp)
|
||||
tickets.register_handlers(dp)
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
@@ -130,11 +141,20 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
admin_updates.register_handlers(dp)
|
||||
admin_backup.register_handlers(dp)
|
||||
admin_system_logs.register_handlers(dp)
|
||||
admin_welcome_text.register_welcome_text_handlers(dp)
|
||||
admin_tickets.register_handlers(dp)
|
||||
admin_reports.register_handlers(dp)
|
||||
admin_bot_configuration.register_handlers(dp)
|
||||
admin_pricing.register_handlers(dp)
|
||||
admin_privacy_policy.register_handlers(dp)
|
||||
admin_public_offer.register_handlers(dp)
|
||||
admin_faq.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+433
-49
@@ -1,7 +1,11 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import html
|
||||
from collections import defaultdict
|
||||
from datetime import time
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
@@ -9,20 +13,35 @@ from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
|
||||
BOT_TOKEN: str
|
||||
BOT_USERNAME: Optional[str] = None
|
||||
ADMIN_IDS: str = ""
|
||||
SUPPORT_USERNAME: str = "@support"
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
# SLA for support tickets
|
||||
SUPPORT_TICKET_SLA_ENABLED: bool = True
|
||||
SUPPORT_TICKET_SLA_MINUTES: int = 5
|
||||
SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: int = 60
|
||||
SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: int = 15
|
||||
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
|
||||
|
||||
ADMIN_REPORTS_ENABLED: bool = False
|
||||
ADMIN_REPORTS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_REPORTS_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_REPORTS_SEND_TIME: Optional[str] = None
|
||||
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
|
||||
DATABASE_URL: str = ""
|
||||
DATABASE_URL: Optional[str] = None
|
||||
|
||||
POSTGRES_HOST: str = "postgres"
|
||||
POSTGRES_PORT: int = 5432
|
||||
@@ -37,8 +56,8 @@ class Settings(BaseSettings):
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
REMNAWAVE_API_URL: str
|
||||
REMNAWAVE_API_KEY: str
|
||||
REMNAWAVE_API_URL: Optional[str] = None
|
||||
REMNAWAVE_API_KEY: Optional[str] = None
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
|
||||
REMNAWAVE_USERNAME: Optional[str] = None
|
||||
@@ -50,18 +69,29 @@ class Settings(BaseSettings):
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: str
|
||||
TRIAL_SQUAD_UUID: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
ENABLE_NOTIFICATIONS: bool = True
|
||||
NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
|
||||
MONITORING_LOGS_RETENTION_DAYS: int = 30
|
||||
NOTIFICATION_CACHE_HOURS: int = 24
|
||||
MONITORING_LOGS_RETENTION_DAYS: int = 30
|
||||
NOTIFICATION_CACHE_HOURS: int = 24
|
||||
|
||||
SERVER_STATUS_MODE: str = "disabled"
|
||||
SERVER_STATUS_EXTERNAL_URL: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_URL: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_USERNAME: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_PASSWORD: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_VERIFY_SSL: bool = True
|
||||
SERVER_STATUS_REQUEST_TIMEOUT: int = 10
|
||||
SERVER_STATUS_ITEMS_PER_PAGE: int = 10
|
||||
|
||||
BASE_SUBSCRIPTION_PRICE: int = 50000
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
|
||||
@@ -103,8 +133,9 @@ class Settings(BaseSettings):
|
||||
REFERRED_USER_REWARD: int = 0
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
|
||||
DEFAULT_AUTOPAY_ENABLED: bool = False
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
|
||||
|
||||
MONITORING_INTERVAL: int = 60
|
||||
@@ -122,6 +153,7 @@ class Settings(BaseSettings):
|
||||
TRIBUTE_API_KEY: Optional[str] = None
|
||||
TRIBUTE_DONATE_LINK: Optional[str] = None
|
||||
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
|
||||
TRIBUTE_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
TRIBUTE_WEBHOOK_PORT: int = 8081
|
||||
|
||||
YOOKASSA_ENABLED: bool = False
|
||||
@@ -131,9 +163,10 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
|
||||
YOOKASSA_VAT_CODE: int = 1
|
||||
YOOKASSA_SBP_ENABLED: bool = False
|
||||
YOOKASSA_PAYMENT_MODE: str = "full_payment"
|
||||
YOOKASSA_PAYMENT_MODE: str = "full_payment"
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = "service"
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
@@ -156,8 +189,48 @@ class Settings(BaseSettings):
|
||||
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
|
||||
|
||||
MULENPAY_ENABLED: bool = False
|
||||
MULENPAY_API_KEY: Optional[str] = None
|
||||
MULENPAY_SECRET_KEY: Optional[str] = None
|
||||
MULENPAY_SHOP_ID: Optional[int] = None
|
||||
MULENPAY_BASE_URL: str = "https://mulenpay.ru/api"
|
||||
MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook"
|
||||
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
|
||||
MULENPAY_LANGUAGE: str = "ru"
|
||||
MULENPAY_VAT_CODE: int = 0
|
||||
MULENPAY_PAYMENT_SUBJECT: int = 4
|
||||
MULENPAY_PAYMENT_MODE: int = 4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
|
||||
PAL24_ENABLED: bool = False
|
||||
PAL24_API_TOKEN: Optional[str] = None
|
||||
PAL24_SHOP_ID: Optional[str] = None
|
||||
PAL24_SIGNATURE_TOKEN: Optional[str] = None
|
||||
PAL24_BASE_URL: str = "https://pal24.pro/api/v1/"
|
||||
PAL24_WEBHOOK_PATH: str = "/pal24-webhook"
|
||||
PAL24_WEBHOOK_PORT: int = 8084
|
||||
PAL24_PAYMENT_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PAL24_REQUEST_TIMEOUT: int = 30
|
||||
PAL24_SBP_BUTTON_TEXT: Optional[str] = None
|
||||
PAL24_CARD_BUTTON_TEXT: Optional[str] = None
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
MINIAPP_PURCHASE_URL: str = ""
|
||||
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
|
||||
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection"
|
||||
MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение"
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_WINDOWS: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_PC: Optional[str] = None
|
||||
HIDE_SUBSCRIPTION_LINK: bool = False
|
||||
ENABLE_LOGO_MODE: bool = True
|
||||
LOGO_FILE: str = "vpn_logo.png"
|
||||
@@ -166,6 +239,7 @@ class Settings(BaseSettings):
|
||||
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/bot.log"
|
||||
@@ -173,6 +247,19 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
|
||||
WEB_API_ENABLED: bool = False
|
||||
WEB_API_HOST: str = "0.0.0.0"
|
||||
WEB_API_PORT: int = 8080
|
||||
WEB_API_WORKERS: int = 1
|
||||
WEB_API_ALLOWED_ORIGINS: str = "*"
|
||||
WEB_API_DOCS_ENABLED: bool = False
|
||||
WEB_API_TITLE: str = "Remnawave Bot Admin API"
|
||||
WEB_API_VERSION: str = "1.0.0"
|
||||
WEB_API_DEFAULT_TOKEN: Optional[str] = None
|
||||
WEB_API_DEFAULT_TOKEN_NAME: str = "Bootstrap Token"
|
||||
WEB_API_TOKEN_HASH_ALGORITHM: str = "sha256"
|
||||
WEB_API_REQUEST_LOGGING: bool = True
|
||||
|
||||
APP_CONFIG_PATH: str = "app-config.json"
|
||||
ENABLE_DEEP_LINKS: bool = True
|
||||
@@ -192,6 +279,66 @@ class Settings(BaseSettings):
|
||||
BACKUP_SEND_ENABLED: bool = False
|
||||
BACKUP_SEND_CHAT_ID: Optional[str] = None
|
||||
BACKUP_SEND_TOPIC_ID: Optional[int] = None
|
||||
|
||||
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
|
||||
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
|
||||
|
||||
@field_validator('SERVER_STATUS_MODE', mode='before')
|
||||
@classmethod
|
||||
def normalize_server_status_mode(cls, value: Optional[str]) -> str:
|
||||
if not value:
|
||||
return "disabled"
|
||||
|
||||
normalized = str(value).strip().lower()
|
||||
aliases = {
|
||||
"off": "disabled",
|
||||
"none": "disabled",
|
||||
"disabled": "disabled",
|
||||
"external": "external_link",
|
||||
"link": "external_link",
|
||||
"url": "external_link",
|
||||
"external_link": "external_link",
|
||||
"miniapp": "external_link_miniapp",
|
||||
"mini_app": "external_link_miniapp",
|
||||
"mini-app": "external_link_miniapp",
|
||||
"webapp": "external_link_miniapp",
|
||||
"web_app": "external_link_miniapp",
|
||||
"web-app": "external_link_miniapp",
|
||||
"external_link_miniapp": "external_link_miniapp",
|
||||
"xray": "xray",
|
||||
"xraychecker": "xray",
|
||||
"xray_metrics": "xray",
|
||||
"metrics": "xray",
|
||||
}
|
||||
|
||||
mode = aliases.get(normalized, normalized)
|
||||
if mode not in {"disabled", "external_link", "external_link_miniapp", "xray"}:
|
||||
raise ValueError(
|
||||
"SERVER_STATUS_MODE must be one of: disabled, external_link, external_link_miniapp, xray"
|
||||
)
|
||||
return mode
|
||||
|
||||
@field_validator('SERVER_STATUS_ITEMS_PER_PAGE', mode='before')
|
||||
@classmethod
|
||||
def ensure_positive_server_status_page_size(cls, value: Optional[int]) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return 10
|
||||
value_int = int(value)
|
||||
return max(1, value_int)
|
||||
except (TypeError, ValueError):
|
||||
return 10
|
||||
|
||||
@field_validator('SERVER_STATUS_REQUEST_TIMEOUT', mode='before')
|
||||
@classmethod
|
||||
def ensure_positive_server_status_timeout(cls, value: Optional[int]) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return 10
|
||||
value_int = int(value)
|
||||
return max(1, value_int)
|
||||
except (TypeError, ValueError):
|
||||
return 10
|
||||
|
||||
@field_validator('LOG_FILE', mode='before')
|
||||
@classmethod
|
||||
@@ -268,6 +415,14 @@ class Settings(BaseSettings):
|
||||
"password": self.REMNAWAVE_PASSWORD,
|
||||
"auth_type": self.REMNAWAVE_AUTH_TYPE
|
||||
}
|
||||
|
||||
def get_pal24_sbp_button_text(self, fallback: str) -> str:
|
||||
value = (self.PAL24_SBP_BUTTON_TEXT or "").strip()
|
||||
return value or fallback
|
||||
|
||||
def get_pal24_card_button_text(self, fallback: str) -> str:
|
||||
value = (self.PAL24_CARD_BUTTON_TEXT or "").strip()
|
||||
return value or fallback
|
||||
|
||||
def get_remnawave_user_delete_mode(self) -> str:
|
||||
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
|
||||
@@ -311,6 +466,15 @@ class Settings(BaseSettings):
|
||||
return [3, 1]
|
||||
except (ValueError, AttributeError):
|
||||
return [3, 1]
|
||||
|
||||
def is_autopay_enabled_by_default(self) -> bool:
|
||||
value = getattr(self, "DEFAULT_AUTOPAY_ENABLED", True)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
try:
|
||||
@@ -322,10 +486,39 @@ class Settings(BaseSettings):
|
||||
return ["ru", "en"]
|
||||
except AttributeError:
|
||||
return ["ru", "en"]
|
||||
|
||||
|
||||
def is_language_selection_enabled(self) -> bool:
|
||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks // 100
|
||||
return f"{rubles} ₽"
|
||||
|
||||
def get_reports_chat_id(self) -> Optional[str]:
|
||||
if self.ADMIN_REPORTS_CHAT_ID:
|
||||
return self.ADMIN_REPORTS_CHAT_ID
|
||||
return self.ADMIN_NOTIFICATIONS_CHAT_ID
|
||||
|
||||
def get_reports_topic_id(self) -> Optional[int]:
|
||||
return self.ADMIN_REPORTS_TOPIC_ID or None
|
||||
|
||||
def get_reports_send_time(self) -> Optional[time]:
|
||||
value = self.ADMIN_REPORTS_SEND_TIME
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
hours_str, minutes_str = value.strip().split(":", 1)
|
||||
hours = int(hours_str)
|
||||
minutes = int(minutes_str)
|
||||
if not (0 <= hours <= 23 and 0 <= minutes <= 59):
|
||||
raise ValueError
|
||||
return time(hour=hours, minute=minutes)
|
||||
except (ValueError, AttributeError):
|
||||
logging.getLogger(__name__).warning(
|
||||
"Некорректное значение ADMIN_REPORTS_SEND_TIME: %s", value
|
||||
)
|
||||
return None
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
return kopeks / 100
|
||||
@@ -335,7 +528,14 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_trial_warning_hours(self) -> int:
|
||||
return self.TRIAL_WARNING_HOURS
|
||||
|
||||
|
||||
def get_bot_username(self) -> Optional[str]:
|
||||
username = getattr(self, "BOT_USERNAME", None)
|
||||
if not username:
|
||||
return None
|
||||
normalized = str(username).strip().lstrip("@")
|
||||
return normalized or None
|
||||
|
||||
def is_notifications_enabled(self) -> bool:
|
||||
return self.ENABLE_NOTIFICATIONS
|
||||
|
||||
@@ -348,9 +548,68 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
def get_miniapp_branding(self) -> Dict[str, Dict[str, Optional[str]]]:
|
||||
def _clean(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
value_str = str(value).strip()
|
||||
return value_str or None
|
||||
|
||||
name_en = _clean(self.MINIAPP_SERVICE_NAME_EN)
|
||||
name_ru = _clean(self.MINIAPP_SERVICE_NAME_RU)
|
||||
desc_en = _clean(self.MINIAPP_SERVICE_DESCRIPTION_EN)
|
||||
desc_ru = _clean(self.MINIAPP_SERVICE_DESCRIPTION_RU)
|
||||
|
||||
default_name = name_en or name_ru or "RemnaWave VPN"
|
||||
default_description = desc_en or desc_ru or "Secure & Fast Connection"
|
||||
|
||||
return {
|
||||
"service_name": {
|
||||
"default": default_name,
|
||||
"en": name_en,
|
||||
"ru": name_ru,
|
||||
},
|
||||
"service_description": {
|
||||
"default": default_description,
|
||||
"en": desc_en,
|
||||
"ru": desc_ru,
|
||||
},
|
||||
}
|
||||
|
||||
def get_app_config_cache_ttl(self) -> int:
|
||||
return self.APP_CONFIG_CACHE_TTL
|
||||
|
||||
def build_external_admin_token(self, bot_username: str) -> str:
|
||||
"""Генерирует детерминированный и криптографически стойкий токен внешней админки."""
|
||||
normalized = (bot_username or "").strip().lstrip("@").lower()
|
||||
if not normalized:
|
||||
raise ValueError("Bot username is required to build external admin token")
|
||||
|
||||
secret = (self.BOT_TOKEN or "").strip()
|
||||
if not secret:
|
||||
raise ValueError("Bot token is required to build external admin token")
|
||||
|
||||
digest = hmac.new(
|
||||
key=secret.encode("utf-8"),
|
||||
msg=f"remnawave.external_admin::{normalized}".encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
return digest[:48]
|
||||
|
||||
def get_external_admin_token(self) -> Optional[str]:
|
||||
token = (self.EXTERNAL_ADMIN_TOKEN or "").strip()
|
||||
return token or None
|
||||
|
||||
def get_external_admin_bot_id(self) -> Optional[int]:
|
||||
try:
|
||||
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
|
||||
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
|
||||
logging.getLogger(__name__).warning(
|
||||
"Некорректный идентификатор бота для внешней админки: %s",
|
||||
self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
|
||||
)
|
||||
return None
|
||||
|
||||
def is_traffic_selectable(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
|
||||
@@ -374,9 +633,24 @@ class Settings(BaseSettings):
|
||||
return "https://t.me/"
|
||||
|
||||
def is_cryptobot_enabled(self) -> bool:
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
self.CRYPTOBOT_API_TOKEN is not None)
|
||||
|
||||
|
||||
def is_mulenpay_enabled(self) -> bool:
|
||||
return (
|
||||
self.MULENPAY_ENABLED
|
||||
and self.MULENPAY_API_KEY is not None
|
||||
and self.MULENPAY_SECRET_KEY is not None
|
||||
and self.MULENPAY_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def is_pal24_enabled(self) -> bool:
|
||||
return (
|
||||
self.PAL24_ENABLED
|
||||
and self.PAL24_API_TOKEN is not None
|
||||
and self.PAL24_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def get_cryptobot_base_url(self) -> str:
|
||||
if self.CRYPTOBOT_TESTNET:
|
||||
return "https://testnet-pay.crypt.bot"
|
||||
@@ -394,6 +668,41 @@ class Settings(BaseSettings):
|
||||
def get_cryptobot_invoice_expires_seconds(self) -> int:
|
||||
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
|
||||
|
||||
def is_happ_cryptolink_mode(self) -> bool:
|
||||
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
|
||||
|
||||
def is_happ_download_button_enabled(self) -> bool:
|
||||
return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED
|
||||
|
||||
def should_hide_subscription_link(self) -> bool:
|
||||
"""Returns True when subscription links must be hidden from the interface."""
|
||||
|
||||
if self.is_happ_cryptolink_mode():
|
||||
return False
|
||||
return self.HIDE_SUBSCRIPTION_LINK
|
||||
|
||||
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
|
||||
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
|
||||
return template or None
|
||||
|
||||
def get_happ_download_link(self, platform: str) -> Optional[str]:
|
||||
platform_key = platform.lower()
|
||||
|
||||
if platform_key == "pc":
|
||||
platform_key = "windows"
|
||||
|
||||
links = {
|
||||
"ios": (self.HAPP_DOWNLOAD_LINK_IOS or "").strip(),
|
||||
"android": (self.HAPP_DOWNLOAD_LINK_ANDROID or "").strip(),
|
||||
"macos": (self.HAPP_DOWNLOAD_LINK_MACOS or "").strip(),
|
||||
"windows": (
|
||||
(self.HAPP_DOWNLOAD_LINK_WINDOWS or "").strip()
|
||||
or (self.HAPP_DOWNLOAD_LINK_PC or "").strip()
|
||||
),
|
||||
}
|
||||
link = links.get(platform_key)
|
||||
return link if link else None
|
||||
|
||||
def is_maintenance_mode(self) -> bool:
|
||||
return self.MAINTENANCE_MODE
|
||||
|
||||
@@ -536,14 +845,42 @@ class Settings(BaseSettings):
|
||||
return (self.BACKUP_SEND_ENABLED and
|
||||
self.get_backup_send_chat_id() is not None)
|
||||
|
||||
def get_referred_user_reward_kopeks(self) -> int:
|
||||
"""Return the referred user reward normalized to kopeks.
|
||||
|
||||
Historically the value was stored in kopeks, however some
|
||||
installations provide it in rubles. To keep backward compatibility we
|
||||
treat any value greater than or equal to one thousand as already being
|
||||
in kopeks (≥ 10 ₽). Smaller positive values are assumed to be provided
|
||||
in rubles and therefore converted to kopeks.
|
||||
"""
|
||||
|
||||
raw_value = getattr(self, "REFERRED_USER_REWARD", 0)
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
if value <= 0:
|
||||
return 0
|
||||
|
||||
if value >= 1000:
|
||||
return value
|
||||
|
||||
return value * 100
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
|
||||
|
||||
return {
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
|
||||
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
|
||||
"referred_user_reward": self.REFERRED_USER_REWARD
|
||||
"referred_user_reward": referred_reward_kopeks,
|
||||
"referred_user_reward_kopeks": referred_reward_kopeks,
|
||||
}
|
||||
|
||||
def is_referral_notifications_enabled(self) -> bool:
|
||||
@@ -740,33 +1077,64 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_support_contact_display_html(self) -> str:
|
||||
return html.escape(self.get_support_contact_display())
|
||||
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
|
||||
|
||||
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
|
||||
if finite_packages:
|
||||
max_package = max(finite_packages, key=lambda x: x["gb"])
|
||||
|
||||
if gb > max_package["gb"]:
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
else:
|
||||
return max_package["price"]
|
||||
|
||||
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
|
||||
if suitable_packages:
|
||||
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
|
||||
return nearest_package["price"]
|
||||
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
|
||||
return 0
|
||||
|
||||
def get_server_status_mode(self) -> str:
|
||||
return self.SERVER_STATUS_MODE
|
||||
|
||||
def is_server_status_enabled(self) -> bool:
|
||||
return self.get_server_status_mode() != "disabled"
|
||||
|
||||
def get_server_status_external_url(self) -> Optional[str]:
|
||||
url = (self.SERVER_STATUS_EXTERNAL_URL or "").strip()
|
||||
return url or None
|
||||
|
||||
def get_server_status_metrics_url(self) -> Optional[str]:
|
||||
url = (self.SERVER_STATUS_METRICS_URL or "").strip()
|
||||
return url or None
|
||||
|
||||
def get_server_status_metrics_auth(self) -> Optional[tuple[str, str]]:
|
||||
username = (self.SERVER_STATUS_METRICS_USERNAME or "").strip()
|
||||
password_raw = self.SERVER_STATUS_METRICS_PASSWORD
|
||||
|
||||
if not username:
|
||||
return None
|
||||
|
||||
password = "" if password_raw is None else str(password_raw)
|
||||
return username, password
|
||||
|
||||
def get_server_status_items_per_page(self) -> int:
|
||||
return max(1, self.SERVER_STATUS_ITEMS_PER_PAGE)
|
||||
|
||||
def get_server_status_request_timeout(self) -> int:
|
||||
return max(1, self.SERVER_STATUS_REQUEST_TIMEOUT)
|
||||
|
||||
def is_web_api_enabled(self) -> bool:
|
||||
return bool(self.WEB_API_ENABLED)
|
||||
|
||||
def get_web_api_allowed_origins(self) -> list[str]:
|
||||
raw = (self.WEB_API_ALLOWED_ORIGINS or "").split(",")
|
||||
origins = [origin.strip() for origin in raw if origin.strip()]
|
||||
return origins or ["*"]
|
||||
|
||||
def get_web_api_docs_config(self) -> Dict[str, Optional[str]]:
|
||||
if self.WEB_API_DOCS_ENABLED:
|
||||
return {
|
||||
"docs_url": "/docs",
|
||||
"redoc_url": "/redoc",
|
||||
"openapi_url": "/openapi.json",
|
||||
}
|
||||
|
||||
return {"docs_url": None, "redoc_url": None, "openapi_url": None}
|
||||
|
||||
def get_support_system_mode(self) -> str:
|
||||
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
def is_support_tickets_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"tickets", "both"}
|
||||
|
||||
def is_support_contact_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"contact", "both"}
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
@@ -777,15 +1145,31 @@ class Settings(BaseSettings):
|
||||
|
||||
settings = Settings()
|
||||
|
||||
PERIOD_PRICES = {
|
||||
14: settings.PRICE_14_DAYS,
|
||||
30: settings.PRICE_30_DAYS,
|
||||
60: settings.PRICE_60_DAYS,
|
||||
90: settings.PRICE_90_DAYS,
|
||||
180: settings.PRICE_180_DAYS,
|
||||
360: settings.PRICE_360_DAYS,
|
||||
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
|
||||
14: "PRICE_14_DAYS",
|
||||
30: "PRICE_30_DAYS",
|
||||
60: "PRICE_60_DAYS",
|
||||
90: "PRICE_90_DAYS",
|
||||
180: "PRICE_180_DAYS",
|
||||
360: "PRICE_360_DAYS",
|
||||
}
|
||||
|
||||
|
||||
def refresh_period_prices() -> None:
|
||||
"""Rebuild cached period price mapping using the latest settings."""
|
||||
|
||||
PERIOD_PRICES.clear()
|
||||
PERIOD_PRICES.update(
|
||||
{
|
||||
days: getattr(settings, field_name, 0)
|
||||
for days, field_name in _PERIOD_PRICE_FIELDS.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
PERIOD_PRICES: Dict[int, int] = {}
|
||||
refresh_period_prices()
|
||||
|
||||
def get_traffic_prices() -> Dict[int, int]:
|
||||
packages = settings.get_traffic_packages()
|
||||
return {package["gb"]: package["price"] for package in packages}
|
||||
|
||||
@@ -9,6 +9,12 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +32,7 @@ async def create_campaign(
|
||||
subscription_traffic_gb: Optional[int] = None,
|
||||
subscription_device_limit: Optional[int] = None,
|
||||
subscription_squads: Optional[List[str]] = None,
|
||||
is_active: bool = True,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
@@ -37,7 +44,7 @@ async def create_campaign(
|
||||
subscription_device_limit=subscription_device_limit,
|
||||
subscription_squads=subscription_squads or [],
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
@@ -129,7 +136,10 @@ async def update_campaign(
|
||||
"is_active",
|
||||
}
|
||||
|
||||
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
|
||||
update_data = {}
|
||||
for key, value in kwargs.items():
|
||||
if key in allowed_fields and value is not None:
|
||||
update_data[key] = value
|
||||
|
||||
if not update_data:
|
||||
return campaign
|
||||
@@ -157,6 +167,19 @@ async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bo
|
||||
return True
|
||||
|
||||
|
||||
async def get_campaign_registration_by_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[AdvertisingCampaignRegistration]:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaignRegistration)
|
||||
.options(selectinload(AdvertisingCampaignRegistration.campaign))
|
||||
.where(AdvertisingCampaignRegistration.user_id == user_id)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_campaign_registration(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -197,6 +220,11 @@ async def get_campaign_statistics(
|
||||
db: AsyncSession,
|
||||
campaign_id: int,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
registrations_query = select(AdvertisingCampaignRegistration.user_id).where(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id
|
||||
)
|
||||
registrations_subquery = registrations_query.subquery()
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(AdvertisingCampaignRegistration.id),
|
||||
@@ -207,6 +235,8 @@ async def get_campaign_statistics(
|
||||
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
)
|
||||
count, total_balance, last_registration = result.one()
|
||||
count = count or 0
|
||||
total_balance = total_balance or 0
|
||||
|
||||
subscription_count_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
@@ -216,12 +246,215 @@ async def get_campaign_statistics(
|
||||
)
|
||||
)
|
||||
)
|
||||
subscription_bonuses_issued = subscription_count_result.scalar() or 0
|
||||
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
)
|
||||
deposits_total = deposits_result.scalar() or 0
|
||||
|
||||
trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
)
|
||||
)
|
||||
trial_users_count = trials_result.scalar() or 0
|
||||
|
||||
active_trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
active_trials_count = active_trials_result.scalar() or 0
|
||||
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
conversion_count = conversions_result.scalar() or 0
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.id.in_(select(registrations_subquery.c.user_id)),
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
paid_users_from_flag = paid_users_result.scalar() or 0
|
||||
|
||||
conversions_rows = await db.execute(
|
||||
select(
|
||||
SubscriptionConversion.user_id,
|
||||
SubscriptionConversion.first_payment_amount_kopeks,
|
||||
SubscriptionConversion.converted_at,
|
||||
)
|
||||
.where(
|
||||
SubscriptionConversion.user_id.in_(
|
||||
select(registrations_subquery.c.user_id)
|
||||
)
|
||||
)
|
||||
.order_by(SubscriptionConversion.converted_at)
|
||||
)
|
||||
conversion_entries = conversions_rows.all()
|
||||
|
||||
subscription_payments_rows = await db.execute(
|
||||
select(
|
||||
Transaction.user_id,
|
||||
Transaction.amount_kopeks,
|
||||
Transaction.created_at,
|
||||
)
|
||||
.where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
.order_by(Transaction.user_id, Transaction.created_at)
|
||||
)
|
||||
subscription_payments = subscription_payments_rows.all()
|
||||
|
||||
subscription_payments_total = 0
|
||||
paid_users_from_transactions = set()
|
||||
conversion_user_ids = set()
|
||||
first_payment_amount_by_user: Dict[int, int] = {}
|
||||
first_payment_time_by_user: Dict[int, Optional[datetime]] = {}
|
||||
|
||||
for user_id, amount_kopeks, converted_at in conversion_entries:
|
||||
conversion_user_ids.add(user_id)
|
||||
amount_value = int(amount_kopeks or 0)
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = converted_at
|
||||
|
||||
for user_id, amount_kopeks, created_at in subscription_payments:
|
||||
amount_value = int(amount_kopeks or 0)
|
||||
subscription_payments_total += amount_value
|
||||
paid_users_from_transactions.add(user_id)
|
||||
|
||||
if user_id not in first_payment_amount_by_user:
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
else:
|
||||
existing_time = first_payment_time_by_user.get(user_id)
|
||||
if existing_time is None and created_at is not None:
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
elif (
|
||||
existing_time is not None
|
||||
and created_at is not None
|
||||
and created_at < existing_time
|
||||
):
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
|
||||
total_revenue = deposits_total + subscription_payments_total
|
||||
|
||||
paid_user_ids = set(paid_users_from_transactions)
|
||||
paid_user_ids.update(conversion_user_ids)
|
||||
paid_users_count = max(len(paid_user_ids), paid_users_from_flag)
|
||||
|
||||
conversion_count = conversion_count or len(paid_user_ids)
|
||||
if conversion_count < len(paid_user_ids):
|
||||
conversion_count = len(paid_user_ids)
|
||||
|
||||
avg_first_payment = 0
|
||||
if first_payment_amount_by_user:
|
||||
avg_first_payment = int(
|
||||
sum(first_payment_amount_by_user.values())
|
||||
/ len(first_payment_amount_by_user)
|
||||
)
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
trial_conversion_rate = 0.0
|
||||
if trial_users_count:
|
||||
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
|
||||
|
||||
avg_revenue_per_user = 0
|
||||
if count:
|
||||
avg_revenue_per_user = int(total_revenue / count)
|
||||
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
)
|
||||
total_revenue = deposits_result.scalar() or 0
|
||||
|
||||
trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
)
|
||||
)
|
||||
trial_users_count = trials_result.scalar() or 0
|
||||
|
||||
active_trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
active_trials_count = active_trials_result.scalar() or 0
|
||||
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
conversion_count = conversions_result.scalar() or 0
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.id.in_(select(registrations_subquery.c.user_id)),
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
paid_users_count = paid_users_result.scalar() or 0
|
||||
|
||||
avg_first_payment_result = await db.execute(
|
||||
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
trial_conversion_rate = 0.0
|
||||
if trial_users_count:
|
||||
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
|
||||
|
||||
avg_revenue_per_user = 0
|
||||
if count:
|
||||
avg_revenue_per_user = int(total_revenue / count)
|
||||
|
||||
return {
|
||||
"registrations": count or 0,
|
||||
"balance_issued": total_balance or 0,
|
||||
"subscription_issued": subscription_count_result.scalar() or 0,
|
||||
"registrations": count,
|
||||
"balance_issued": total_balance,
|
||||
"subscription_issued": subscription_bonuses_issued,
|
||||
"last_registration": last_registration,
|
||||
"total_revenue_kopeks": total_revenue,
|
||||
"trial_users_count": trial_users_count,
|
||||
"active_trials_count": active_trials_count,
|
||||
"conversion_count": conversion_count,
|
||||
"paid_users_count": paid_users_count,
|
||||
"conversion_rate": conversion_rate,
|
||||
"trial_conversion_rate": trial_conversion_rate,
|
||||
"avg_revenue_per_user_kopeks": avg_revenue_per_user,
|
||||
"avg_first_payment_kopeks": avg_first_payment,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.database.models import DiscountOffer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def upsert_discount_offer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
subscription_id: Optional[int],
|
||||
notification_type: str,
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
effect_type: str = "percent_discount",
|
||||
extra_data: Optional[dict] = None,
|
||||
) -> DiscountOffer:
|
||||
"""Create or refresh a discount offer for a user."""
|
||||
|
||||
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.notification_type == notification_type,
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
)
|
||||
offer = result.scalars().first()
|
||||
|
||||
if offer and offer.claimed_at is None:
|
||||
offer.discount_percent = discount_percent
|
||||
offer.bonus_amount_kopeks = bonus_amount_kopeks
|
||||
offer.expires_at = expires_at
|
||||
offer.subscription_id = subscription_id
|
||||
offer.effect_type = effect_type
|
||||
offer.extra_data = extra_data
|
||||
else:
|
||||
offer = DiscountOffer(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
notification_type=notification_type,
|
||||
discount_percent=discount_percent,
|
||||
bonus_amount_kopeks=bonus_amount_kopeks,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
effect_type=effect_type,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
return offer
|
||||
|
||||
|
||||
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.where(DiscountOffer.id == offer_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_discount_offers(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
user_id: Optional[int] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> List[DiscountOffer]:
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(DiscountOffer.user_id == user_id)
|
||||
if notification_type:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(DiscountOffer.is_active == is_active)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def list_active_discount_offers_for_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> List[DiscountOffer]:
|
||||
"""Return active (not yet claimed) offers for a user."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.expires_at > now,
|
||||
)
|
||||
.order_by(DiscountOffer.expires_at.asc())
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_discount_offers(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> int:
|
||||
stmt = select(func.count(DiscountOffer.id))
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(DiscountOffer.user_id == user_id)
|
||||
if notification_type:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(DiscountOffer.is_active == is_active)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def mark_offer_claimed(
|
||||
db: AsyncSession,
|
||||
offer: DiscountOffer,
|
||||
*,
|
||||
details: Optional[dict] = None,
|
||||
) -> DiscountOffer:
|
||||
offer.claimed_at = datetime.utcnow()
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=offer.user_id,
|
||||
offer_id=offer.id,
|
||||
action="claimed",
|
||||
source=offer.notification_type,
|
||||
percent=offer.discount_percent,
|
||||
effect_type=offer.effect_type,
|
||||
details=details,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer claim log for offer %s: %s",
|
||||
offer.id,
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer claim log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
return offer
|
||||
|
||||
|
||||
async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.expires_at < now,
|
||||
)
|
||||
)
|
||||
offers = result.scalars().all()
|
||||
if not offers:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
log_payloads = []
|
||||
for offer in offers:
|
||||
offer.is_active = False
|
||||
count += 1
|
||||
log_payloads.append(
|
||||
{
|
||||
"user_id": offer.user_id,
|
||||
"offer_id": offer.id,
|
||||
"source": offer.notification_type,
|
||||
"percent": offer.discount_percent,
|
||||
"effect_type": offer.effect_type,
|
||||
}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
for payload in log_payloads:
|
||||
if not payload.get("user_id"):
|
||||
continue
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=payload["user_id"],
|
||||
offer_id=payload["offer_id"],
|
||||
action="disabled",
|
||||
source=payload.get("source"),
|
||||
percent=payload.get("percent"),
|
||||
effect_type=payload.get("effect_type"),
|
||||
details={"reason": "offer_expired"},
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer disable log for offer %s: %s",
|
||||
payload.get("offer_id"),
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer disable log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
async def get_latest_claimed_offer_for_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[DiscountOffer]:
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.claimed_at.isnot(None),
|
||||
)
|
||||
.order_by(DiscountOffer.claimed_at.desc())
|
||||
)
|
||||
|
||||
if source:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == source)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
|
||||
result = await db.execute(
|
||||
select(FaqSetting).where(FaqSetting.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
|
||||
setting = await get_faq_setting(db, language)
|
||||
|
||||
if setting:
|
||||
setting.is_enabled = bool(enabled)
|
||||
setting.updated_at = datetime.utcnow()
|
||||
else:
|
||||
setting = FaqSetting(
|
||||
language=language,
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(setting)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус FAQ для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if setting.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return setting
|
||||
|
||||
|
||||
async def upsert_faq_setting(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
|
||||
return await set_faq_enabled(db, language, enabled)
|
||||
|
||||
|
||||
async def get_faq_pages(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> list[FaqPage]:
|
||||
query = select(FaqPage).where(FaqPage.language == language)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(FaqPage.is_active.is_(True))
|
||||
|
||||
query = query.order_by(FaqPage.display_order.asc(), FaqPage.id.asc())
|
||||
|
||||
result = await db.execute(query)
|
||||
pages = list(result.scalars().all())
|
||||
return pages
|
||||
|
||||
|
||||
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
|
||||
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_faq_page(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
language: str,
|
||||
title: str,
|
||||
content: str,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: bool = True,
|
||||
) -> FaqPage:
|
||||
if display_order is None:
|
||||
result = await db.execute(
|
||||
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
|
||||
)
|
||||
max_order = result.scalar() or 0
|
||||
display_order = max_order + 1
|
||||
|
||||
page = FaqPage(
|
||||
language=language,
|
||||
title=title,
|
||||
content=content,
|
||||
display_order=display_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
db.add(page)
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
async def update_faq_page(
|
||||
db: AsyncSession,
|
||||
page: FaqPage,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> FaqPage:
|
||||
if title is not None:
|
||||
page.title = title
|
||||
if content is not None:
|
||||
page.content = content
|
||||
if display_order is not None:
|
||||
page.display_order = display_order
|
||||
if is_active is not None:
|
||||
page.is_active = bool(is_active)
|
||||
|
||||
page.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info("✅ Страница FAQ %s обновлена", page.id)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
|
||||
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
|
||||
await db.commit()
|
||||
logger.info("🗑️ Страница FAQ %s удалена", page_id)
|
||||
|
||||
|
||||
async def bulk_update_order(
|
||||
db: AsyncSession,
|
||||
pages: Iterable[tuple[int, int]],
|
||||
) -> None:
|
||||
for page_id, order in pages:
|
||||
await db.execute(
|
||||
update(FaqPage)
|
||||
.where(FaqPage.id == page_id)
|
||||
.values(display_order=order, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
MainMenuButton,
|
||||
MainMenuButtonActionType,
|
||||
MainMenuButtonVisibility,
|
||||
)
|
||||
|
||||
|
||||
async def count_main_menu_buttons(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count()).select_from(MainMenuButton))
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def get_main_menu_buttons(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> list[MainMenuButton]:
|
||||
stmt = select(MainMenuButton).order_by(
|
||||
MainMenuButton.display_order.asc(),
|
||||
MainMenuButton.id.asc(),
|
||||
)
|
||||
|
||||
if offset is not None:
|
||||
stmt = stmt.offset(offset)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_main_menu_button_by_id(
|
||||
db: AsyncSession, button_id: int
|
||||
) -> MainMenuButton | None:
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).where(MainMenuButton.id == button_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_next_display_order(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.max(MainMenuButton.display_order)))
|
||||
current_max = result.scalar()
|
||||
return (int(current_max) if current_max is not None else 0) + 1
|
||||
|
||||
|
||||
def _enum_value(value, enum_cls):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, enum_cls):
|
||||
return value.value
|
||||
return str(value)
|
||||
|
||||
|
||||
async def create_main_menu_button(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
text: str,
|
||||
action_type: MainMenuButtonActionType | str,
|
||||
action_value: str,
|
||||
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
|
||||
is_active: bool = True,
|
||||
display_order: Optional[int] = None,
|
||||
) -> MainMenuButton:
|
||||
if display_order is None:
|
||||
display_order = await get_next_display_order(db)
|
||||
|
||||
button = MainMenuButton(
|
||||
text=text,
|
||||
action_type=_enum_value(action_type, MainMenuButtonActionType),
|
||||
action_value=action_value,
|
||||
visibility=_enum_value(visibility, MainMenuButtonVisibility)
|
||||
or MainMenuButtonVisibility.ALL.value,
|
||||
is_active=bool(is_active),
|
||||
display_order=int(display_order),
|
||||
)
|
||||
|
||||
db.add(button)
|
||||
await db.commit()
|
||||
await db.refresh(button)
|
||||
return button
|
||||
|
||||
|
||||
async def update_main_menu_button(
|
||||
db: AsyncSession,
|
||||
button: MainMenuButton,
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
action_type: MainMenuButtonActionType | str | None = None,
|
||||
action_value: Optional[str] = None,
|
||||
visibility: MainMenuButtonVisibility | str | None = None,
|
||||
is_active: Optional[bool] = None,
|
||||
display_order: Optional[int] = None,
|
||||
) -> MainMenuButton:
|
||||
if text is not None:
|
||||
button.text = text
|
||||
if action_type is not None:
|
||||
button.action_type = _enum_value(action_type, MainMenuButtonActionType)
|
||||
if action_value is not None:
|
||||
button.action_value = action_value
|
||||
if visibility is not None:
|
||||
button.visibility = _enum_value(visibility, MainMenuButtonVisibility)
|
||||
if is_active is not None:
|
||||
button.is_active = bool(is_active)
|
||||
if display_order is not None:
|
||||
button.display_order = int(display_order)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(button)
|
||||
return button
|
||||
|
||||
|
||||
async def delete_main_menu_button(db: AsyncSession, button: MainMenuButton) -> None:
|
||||
await db.delete(button)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def reorder_main_menu_buttons(
|
||||
db: AsyncSession,
|
||||
ordered_ids: Sequence[int],
|
||||
) -> None:
|
||||
if not ordered_ids:
|
||||
return
|
||||
|
||||
order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)}
|
||||
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))
|
||||
)
|
||||
buttons = result.scalars().all()
|
||||
|
||||
for button in buttons:
|
||||
desired_order = order_map.get(button.id)
|
||||
if desired_order is not None:
|
||||
button.display_order = desired_order
|
||||
|
||||
await db.commit()
|
||||
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_mulenpay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
uuid: str,
|
||||
description: str,
|
||||
payment_url: Optional[str],
|
||||
mulen_payment_id: Optional[int],
|
||||
currency: str,
|
||||
status: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> MulenPayPayment:
|
||||
payment = MulenPayPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
uuid=uuid,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
mulen_payment_id=mulen_payment_id,
|
||||
currency=currency,
|
||||
status=status,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
payment.mulen_payment_id,
|
||||
uuid,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_local_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_uuid(
|
||||
db: AsyncSession, uuid: str
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_mulen_id(
|
||||
db: AsyncSession, mulen_payment_id: int
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(
|
||||
MulenPayPayment.mulen_payment_id == mulen_payment_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_mulenpay_payment_status(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: MulenPayPayment,
|
||||
status: str,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
callback_payload: Optional[dict] = None,
|
||||
mulen_payment_id: Optional[int] = None,
|
||||
) -> MulenPayPayment:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at:
|
||||
payment.paid_at = paid_at
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
||||
payment.mulen_payment_id = mulen_payment_id
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_mulenpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: MulenPayPayment,
|
||||
transaction_id: int,
|
||||
) -> MulenPayPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -50,3 +50,17 @@ async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notification_by_type(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
SentNotification.subscription_id == subscription_id,
|
||||
SentNotification.notification_type == notification_type,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""CRUD helpers for PayPalych (Pal24) payments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Pal24Payment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_pal24_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
bill_id: str,
|
||||
amount_kopeks: int,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
type_: str,
|
||||
currency: str,
|
||||
link_url: Optional[str],
|
||||
link_page_url: Optional[str],
|
||||
order_id: Optional[str] = None,
|
||||
ttl: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Payment:
|
||||
payment = Pal24Payment(
|
||||
user_id=user_id,
|
||||
bill_id=bill_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
type=type_,
|
||||
link_url=link_url,
|
||||
link_page_url=link_page_url,
|
||||
metadata_json=metadata or {},
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)",
|
||||
payment.id,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
status,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_pal24_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: Pal24Payment,
|
||||
*,
|
||||
status: str,
|
||||
is_active: Optional[bool] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
payment_id: Optional[str] = None,
|
||||
payment_status: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
balance_amount: Optional[str] = None,
|
||||
balance_currency: Optional[str] = None,
|
||||
payer_account: Optional[str] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Payment:
|
||||
update_values: Dict[str, Any] = {
|
||||
"status": status,
|
||||
}
|
||||
|
||||
if is_active is not None:
|
||||
update_values["is_active"] = is_active
|
||||
if is_paid is not None:
|
||||
update_values["is_paid"] = is_paid
|
||||
if payment_id is not None:
|
||||
update_values["payment_id"] = payment_id
|
||||
if payment_status is not None:
|
||||
update_values["payment_status"] = payment_status
|
||||
if payment_method is not None:
|
||||
update_values["payment_method"] = payment_method
|
||||
if balance_amount is not None:
|
||||
update_values["balance_amount"] = balance_amount
|
||||
if balance_currency is not None:
|
||||
update_values["balance_currency"] = balance_currency
|
||||
if payer_account is not None:
|
||||
update_values["payer_account"] = payer_account
|
||||
if callback_payload is not None:
|
||||
update_values["callback_payload"] = callback_payload
|
||||
|
||||
update_values["last_status"] = status
|
||||
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment.id)
|
||||
.values(**update_values)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Pal24 платеж %s: статус=%s, is_paid=%s",
|
||||
payment.bill_id,
|
||||
payment.status,
|
||||
payment.is_paid,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_pal24_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
payment: Pal24Payment,
|
||||
transaction_id: int,
|
||||
) -> Pal24Payment:
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment.id)
|
||||
.values(transaction_id=transaction_id)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
"Pal24 платеж %s привязан к транзакции %s",
|
||||
payment.bill_id,
|
||||
transaction_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PrivacyPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
|
||||
result = await db.execute(
|
||||
select(PrivacyPolicy).where(PrivacyPolicy.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_privacy_policy(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
*,
|
||||
enable_if_new: bool = True,
|
||||
) -> PrivacyPolicy:
|
||||
policy = await get_privacy_policy(db, language)
|
||||
|
||||
if policy:
|
||||
policy.content = content or ""
|
||||
policy.updated_at = datetime.utcnow()
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
content=content or "",
|
||||
is_enabled=True if enable_if_new else False,
|
||||
)
|
||||
db.add(policy)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
logger.info(
|
||||
"✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
|
||||
language,
|
||||
policy.id,
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
async def set_privacy_policy_enabled(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PrivacyPolicy:
|
||||
policy = await get_privacy_policy(db, language)
|
||||
|
||||
if policy:
|
||||
policy.is_enabled = bool(enabled)
|
||||
policy.updated_at = datetime.utcnow()
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
content="",
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(policy)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус политики конфиденциальности для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if policy.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return policy
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -7,25 +7,77 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User
|
||||
|
||||
|
||||
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
|
||||
if not period_discounts:
|
||||
return {}
|
||||
|
||||
normalized: Dict[int, int] = {}
|
||||
|
||||
for key, value in period_discounts.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized[period] = max(0, min(100, percent))
|
||||
|
||||
return normalized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Tuple[PromoGroup, int]]:
|
||||
result = await db.execute(
|
||||
query = (
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
|
||||
)
|
||||
|
||||
if offset:
|
||||
query = query.offset(offset)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
.order_by(PromoGroup.auto_assign_total_spent_kopeks, PromoGroup.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def has_auto_assign_promo_groups(db: AsyncSession) -> bool:
|
||||
result = await db.execute(
|
||||
select(func.count(PromoGroup.id))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
)
|
||||
return bool(result.scalar_one())
|
||||
|
||||
|
||||
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
|
||||
return await db.get(PromoGroup, group_id)
|
||||
|
||||
|
||||
async def count_promo_groups(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(PromoGroup.id)))
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.is_default.is_(True))
|
||||
@@ -40,25 +92,56 @@ async def create_promo_group(
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: bool = True,
|
||||
is_default: bool = False,
|
||||
) -> PromoGroup:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
|
||||
auto_assign_total_spent_kopeks = (
|
||||
max(0, auto_assign_total_spent_kopeks)
|
||||
if auto_assign_total_spent_kopeks is not None
|
||||
else None
|
||||
)
|
||||
|
||||
existing_default = await get_default_promo_group(db)
|
||||
should_be_default = existing_default is None or is_default
|
||||
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
is_default=False,
|
||||
period_discounts=normalized_period_discounts or None,
|
||||
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=bool(apply_discounts_to_addons),
|
||||
is_default=should_be_default,
|
||||
)
|
||||
|
||||
db.add(promo_group)
|
||||
await db.flush()
|
||||
|
||||
if should_be_default and existing_default and existing_default.id != promo_group.id:
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id != promo_group.id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(promo_group)
|
||||
|
||||
logger.info(
|
||||
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)",
|
||||
"Создана промогруппа '%s' (default=%s) с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
|
||||
promo_group.name,
|
||||
promo_group.is_default,
|
||||
promo_group.server_discount_percent,
|
||||
promo_group.traffic_discount_percent,
|
||||
promo_group.device_discount_percent,
|
||||
normalized_period_discounts,
|
||||
(auto_assign_total_spent_kopeks or 0) / 100,
|
||||
"on" if promo_group.apply_discounts_to_addons else "off",
|
||||
)
|
||||
|
||||
return promo_group
|
||||
@@ -72,6 +155,10 @@ async def update_promo_group(
|
||||
server_discount_percent: Optional[int] = None,
|
||||
traffic_discount_percent: Optional[int] = None,
|
||||
device_discount_percent: Optional[int] = None,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: Optional[bool] = None,
|
||||
is_default: Optional[bool] = None,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
@@ -81,6 +168,43 @@ async def update_promo_group(
|
||||
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
|
||||
if device_discount_percent is not None:
|
||||
group.device_discount_percent = max(0, min(100, device_discount_percent))
|
||||
if period_discounts is not None:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
group.period_discounts = normalized_period_discounts or None
|
||||
if auto_assign_total_spent_kopeks is not None:
|
||||
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
|
||||
if apply_discounts_to_addons is not None:
|
||||
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
|
||||
|
||||
if is_default is not None:
|
||||
if is_default:
|
||||
group.is_default = True
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id != group.id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
else:
|
||||
if group.is_default:
|
||||
group.is_default = False
|
||||
await db.flush()
|
||||
replacement = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.id != group.id)
|
||||
.order_by(PromoGroup.id)
|
||||
.limit(1)
|
||||
)
|
||||
new_default = replacement.scalars().first()
|
||||
if new_default:
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id == new_default.id)
|
||||
.values(is_default=True)
|
||||
)
|
||||
else:
|
||||
# Не допускаем состояния без базовой промогруппы
|
||||
group.is_default = True
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
@@ -103,6 +227,7 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
logger.error("Не найдена базовая промогруппа для reassignment")
|
||||
return False
|
||||
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoOfferLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def log_promo_offer_action(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int],
|
||||
offer_id: Optional[int],
|
||||
action: str,
|
||||
source: Optional[str] = None,
|
||||
percent: Optional[int] = None,
|
||||
effect_type: Optional[str] = None,
|
||||
details: Optional[Dict[str, object]] = None,
|
||||
commit: bool = True,
|
||||
) -> PromoOfferLog:
|
||||
"""Persist a promo offer log entry."""
|
||||
|
||||
entry = PromoOfferLog(
|
||||
user_id=user_id,
|
||||
offer_id=offer_id,
|
||||
action=action,
|
||||
source=source,
|
||||
percent=percent,
|
||||
effect_type=effect_type,
|
||||
details=(details or {}).copy(),
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
if commit:
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
except Exception:
|
||||
logger.exception("Failed to commit promo offer log entry")
|
||||
raise
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def list_promo_offer_logs(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
offer_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> Tuple[List[PromoOfferLog], int]:
|
||||
stmt = (
|
||||
select(PromoOfferLog)
|
||||
.options(
|
||||
selectinload(PromoOfferLog.user),
|
||||
selectinload(PromoOfferLog.offer),
|
||||
)
|
||||
.order_by(PromoOfferLog.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(PromoOfferLog.user_id == user_id)
|
||||
if offer_id is not None:
|
||||
stmt = stmt.where(PromoOfferLog.offer_id == offer_id)
|
||||
if action:
|
||||
stmt = stmt.where(PromoOfferLog.action == action)
|
||||
if source:
|
||||
stmt = stmt.where(PromoOfferLog.source == source)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
|
||||
count_stmt = select(func.count(PromoOfferLog.id))
|
||||
if user_id is not None:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.user_id == user_id)
|
||||
if offer_id is not None:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.offer_id == offer_id)
|
||||
if action:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.action == action)
|
||||
if source:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.source == source)
|
||||
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
return logs, total
|
||||
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PromoOfferTemplate
|
||||
|
||||
|
||||
UPDATED_TEMPLATE_MESSAGES = {
|
||||
"extend_discount": (
|
||||
"💎 Экономия {discount_percent}% при продлении\n\n"
|
||||
"Скидка суммируется с промогруппой и действует один раз.\n"
|
||||
"Срок действия предложения — {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
"purchase_discount": (
|
||||
"🎯 Вернитесь со скидкой {discount_percent}%\n\n"
|
||||
"Скидка суммируется с промогруппой и действует один раз.\n"
|
||||
"Предложение действует {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
LEGACY_TEMPLATE_MESSAGES = {
|
||||
"extend_discount": (
|
||||
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
|
||||
"Активируйте предложение и получите дополнительную скидку на оплату продления. "
|
||||
"Она суммируется с вашими промогрупповыми скидками и действует один раз.\n"
|
||||
"Срок действия предложения — {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
"purchase_discount": (
|
||||
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n"
|
||||
"После активации мы применим дополнительную скидку к вашей следующей оплате подписки. "
|
||||
"Скидка суммируется с промогруппой и действует один раз.\n"
|
||||
"Предложение действует {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_TEMPLATES: tuple[dict, ...] = (
|
||||
{
|
||||
"offer_type": "test_access",
|
||||
"name": "Тестовые сервера",
|
||||
"message_text": (
|
||||
"🔥 <b>Испытайте новые сервера</b>\n\n"
|
||||
"Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n"
|
||||
"Предложение действительно {valid_hours} ч."
|
||||
),
|
||||
"button_text": "🚀 Попробовать серверы",
|
||||
"valid_hours": 24,
|
||||
"discount_percent": 0,
|
||||
"bonus_amount_kopeks": 0,
|
||||
"active_discount_hours": None,
|
||||
"test_duration_hours": 24,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
{
|
||||
"offer_type": "extend_discount",
|
||||
"name": "Скидка на продление",
|
||||
"message_text": UPDATED_TEMPLATE_MESSAGES["extend_discount"],
|
||||
"button_text": "🎁 Получить скидку",
|
||||
"valid_hours": 24,
|
||||
"discount_percent": 20,
|
||||
"bonus_amount_kopeks": 0,
|
||||
"active_discount_hours": 24,
|
||||
"test_duration_hours": None,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
{
|
||||
"offer_type": "purchase_discount",
|
||||
"name": "Скидка на покупку",
|
||||
"message_text": UPDATED_TEMPLATE_MESSAGES["purchase_discount"],
|
||||
"button_text": "🎁 Забрать скидку",
|
||||
"valid_hours": 48,
|
||||
"discount_percent": 25,
|
||||
"bonus_amount_kopeks": 0,
|
||||
"active_discount_hours": 48,
|
||||
"test_duration_hours": None,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _format_template_fields(payload: dict) -> dict:
|
||||
data = dict(payload)
|
||||
data.setdefault("valid_hours", 24)
|
||||
data.setdefault("discount_percent", 0)
|
||||
data.setdefault("bonus_amount_kopeks", 0)
|
||||
data.setdefault("active_discount_hours", None)
|
||||
data.setdefault("test_duration_hours", None)
|
||||
data.setdefault("test_squad_uuids", [])
|
||||
return data
|
||||
|
||||
|
||||
async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]:
|
||||
templates: List[PromoOfferTemplate] = []
|
||||
|
||||
for template_data in DEFAULT_TEMPLATES:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"])
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data["offer_type"])
|
||||
legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data["offer_type"])
|
||||
should_update = False
|
||||
|
||||
if new_message and legacy_message and existing.message_text == legacy_message:
|
||||
should_update = True
|
||||
elif new_message and (
|
||||
"{bonus_amount" in existing.message_text or "Мы начислим" in existing.message_text
|
||||
):
|
||||
should_update = True
|
||||
|
||||
if should_update and new_message:
|
||||
existing.message_text = new_message
|
||||
existing.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
|
||||
target_active_hours = template_data.get("active_discount_hours")
|
||||
if (
|
||||
target_active_hours is not None
|
||||
and target_active_hours > 0
|
||||
and not existing.active_discount_hours
|
||||
):
|
||||
existing.active_discount_hours = target_active_hours
|
||||
existing.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
templates.append(existing)
|
||||
continue
|
||||
|
||||
payload = _format_template_fields(template_data)
|
||||
template = PromoOfferTemplate(
|
||||
name=payload["name"],
|
||||
offer_type=payload["offer_type"],
|
||||
message_text=payload["message_text"],
|
||||
button_text=payload["button_text"],
|
||||
valid_hours=payload["valid_hours"],
|
||||
discount_percent=payload["discount_percent"],
|
||||
bonus_amount_kopeks=payload["bonus_amount_kopeks"],
|
||||
active_discount_hours=payload["active_discount_hours"],
|
||||
test_duration_hours=payload["test_duration_hours"],
|
||||
test_squad_uuids=payload["test_squad_uuids"],
|
||||
is_active=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
templates.append(template)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_promo_offer_template(
|
||||
db: AsyncSession,
|
||||
template: PromoOfferTemplate,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
message_text: Optional[str] = None,
|
||||
button_text: Optional[str] = None,
|
||||
valid_hours: Optional[int] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
bonus_amount_kopeks: Optional[int] = None,
|
||||
active_discount_hours: Optional[int] = None,
|
||||
test_duration_hours: Optional[int] = None,
|
||||
test_squad_uuids: Optional[Iterable[str]] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> PromoOfferTemplate:
|
||||
if name is not None:
|
||||
template.name = name
|
||||
if message_text is not None:
|
||||
template.message_text = message_text
|
||||
if button_text is not None:
|
||||
template.button_text = button_text
|
||||
if valid_hours is not None:
|
||||
template.valid_hours = valid_hours
|
||||
if discount_percent is not None:
|
||||
template.discount_percent = discount_percent
|
||||
if bonus_amount_kopeks is not None:
|
||||
template.bonus_amount_kopeks = bonus_amount_kopeks
|
||||
if active_discount_hours is not None:
|
||||
template.active_discount_hours = active_discount_hours
|
||||
if test_duration_hours is not None:
|
||||
template.test_duration_hours = test_duration_hours
|
||||
if test_squad_uuids is not None:
|
||||
template.test_squad_uuids = list(test_squad_uuids)
|
||||
if is_active is not None:
|
||||
template.is_active = is_active
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PublicOffer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]:
|
||||
result = await db.execute(
|
||||
select(PublicOffer).where(PublicOffer.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_public_offer(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
*,
|
||||
enable_if_new: bool = True,
|
||||
) -> PublicOffer:
|
||||
offer = await get_public_offer(db, language)
|
||||
|
||||
if offer:
|
||||
offer.content = content or ""
|
||||
offer.updated_at = datetime.utcnow()
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
content=content or "",
|
||||
is_enabled=True if enable_if_new else False,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
logger.info(
|
||||
"✅ Публичная оферта для языка %s обновлена (ID: %s)",
|
||||
language,
|
||||
offer.id,
|
||||
)
|
||||
|
||||
return offer
|
||||
|
||||
|
||||
async def set_public_offer_enabled(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PublicOffer:
|
||||
offer = await get_public_offer(db, language)
|
||||
|
||||
if offer:
|
||||
offer.is_enabled = bool(enabled)
|
||||
offer.updated_at = datetime.utcnow()
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
content="",
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус публичной оферты для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if offer.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return offer
|
||||
@@ -1,14 +1,34 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy import select, and_, func, update, delete, text
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import (
|
||||
select,
|
||||
and_,
|
||||
func,
|
||||
update,
|
||||
delete,
|
||||
text,
|
||||
or_,
|
||||
cast,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad, SubscriptionServer, Subscription
|
||||
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_default_promo_group_id(db: AsyncSession) -> Optional[int]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_server_squad(
|
||||
db: AsyncSession,
|
||||
squad_uuid: str,
|
||||
@@ -18,9 +38,31 @@ async def create_server_squad(
|
||||
price_kopeks: int = 0,
|
||||
description: str = None,
|
||||
max_users: int = None,
|
||||
is_available: bool = True
|
||||
is_available: bool = True,
|
||||
is_trial_eligible: bool = False,
|
||||
promo_group_ids: Optional[Iterable[int]] = None,
|
||||
) -> ServerSquad:
|
||||
|
||||
|
||||
normalized_group_ids: Sequence[int]
|
||||
if promo_group_ids is None:
|
||||
default_id = await _get_default_promo_group_id(db)
|
||||
normalized_group_ids = [default_id] if default_id is not None else []
|
||||
else:
|
||||
normalized_group_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
|
||||
|
||||
if not normalized_group_ids:
|
||||
raise ValueError("Server squad must be linked to at least one promo group")
|
||||
|
||||
promo_groups_result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(normalized_group_ids))
|
||||
)
|
||||
promo_groups = promo_groups_result.scalars().all()
|
||||
|
||||
if len(promo_groups) != len(normalized_group_ids):
|
||||
logger.warning(
|
||||
"Не все промогруппы найдены при создании сервера %s", display_name
|
||||
)
|
||||
|
||||
server_squad = ServerSquad(
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=display_name,
|
||||
@@ -29,9 +71,11 @@ async def create_server_squad(
|
||||
price_kopeks=price_kopeks,
|
||||
description=description,
|
||||
max_users=max_users,
|
||||
is_available=is_available
|
||||
is_available=is_available,
|
||||
is_trial_eligible=is_trial_eligible,
|
||||
allowed_promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
|
||||
db.add(server_squad)
|
||||
await db.commit()
|
||||
await db.refresh(server_squad)
|
||||
@@ -46,9 +90,11 @@ async def get_server_squad_by_uuid(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid)
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.squad_uuid == squad_uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().unique().one_or_none()
|
||||
|
||||
|
||||
async def get_server_squad_by_id(
|
||||
@@ -57,9 +103,11 @@ async def get_server_squad_by_id(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.id == server_id)
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().unique().one_or_none()
|
||||
|
||||
|
||||
async def get_all_server_squads(
|
||||
@@ -91,14 +139,59 @@ async def get_all_server_squads(
|
||||
return servers, total_count
|
||||
|
||||
|
||||
async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
async def get_available_server_squads(
|
||||
db: AsyncSession,
|
||||
promo_group_id: Optional[int] = None,
|
||||
) -> List[ServerSquad]:
|
||||
|
||||
query = (
|
||||
select(ServerSquad)
|
||||
.where(ServerSquad.is_available == True)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.is_available.is_(True))
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
if promo_group_id is not None:
|
||||
query = query.join(ServerSquad.allowed_promo_groups).where(
|
||||
PromoGroup.id == promo_group_id
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def update_server_squad_promo_groups(
|
||||
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
|
||||
) -> Optional[ServerSquad]:
|
||||
unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
|
||||
|
||||
if not unique_ids:
|
||||
raise ValueError("Нужно выбрать хотя бы одну промогруппу")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(unique_ids))
|
||||
)
|
||||
promo_groups = result.scalars().all()
|
||||
|
||||
if not promo_groups:
|
||||
raise ValueError("Не найдены промогруппы для обновления сервера")
|
||||
|
||||
server.allowed_promo_groups = promo_groups
|
||||
await db.commit()
|
||||
await db.refresh(server)
|
||||
|
||||
logger.info(
|
||||
"Обновлены промогруппы сервера %s (ID: %s): %s",
|
||||
server.display_name,
|
||||
server.id,
|
||||
", ".join(pg.name for pg in promo_groups),
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
async def update_server_squad(
|
||||
@@ -109,7 +202,7 @@ async def update_server_squad(
|
||||
|
||||
valid_fields = {
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order'
|
||||
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
|
||||
}
|
||||
|
||||
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
|
||||
@@ -153,10 +246,10 @@ async def sync_with_remnawave(
|
||||
db: AsyncSession,
|
||||
remnawave_squads: List[dict]
|
||||
) -> Tuple[int, int, int]:
|
||||
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
disabled = 0
|
||||
removed = 0
|
||||
|
||||
existing_servers = {}
|
||||
result = await db.execute(select(ServerSquad))
|
||||
@@ -186,19 +279,187 @@ async def sync_with_remnawave(
|
||||
)
|
||||
created += 1
|
||||
|
||||
for uuid, server in existing_servers.items():
|
||||
if uuid not in remnawave_uuids and server.is_available:
|
||||
server.is_available = False
|
||||
disabled += 1
|
||||
|
||||
removed_servers = [
|
||||
server for uuid, server in existing_servers.items()
|
||||
if uuid not in remnawave_uuids
|
||||
]
|
||||
|
||||
if removed_servers:
|
||||
removed_ids = [server.id for server in removed_servers]
|
||||
removed_uuids = {server.squad_uuid for server in removed_servers}
|
||||
|
||||
subscription_ids_result = await db.execute(
|
||||
select(SubscriptionServer.subscription_id)
|
||||
.where(SubscriptionServer.server_squad_id.in_(removed_ids))
|
||||
)
|
||||
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
|
||||
|
||||
for server in removed_servers:
|
||||
logger.info(
|
||||
"🗑️ Удаляется сервер %s (UUID: %s)",
|
||||
server.display_name,
|
||||
server.squad_uuid,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
|
||||
)
|
||||
|
||||
subscriptions_to_update: dict[int, Subscription] = {}
|
||||
|
||||
if subscription_ids:
|
||||
subscriptions_result = await db.execute(
|
||||
select(Subscription).where(Subscription.id.in_(subscription_ids))
|
||||
)
|
||||
for subscription in subscriptions_result.scalars().unique().all():
|
||||
subscriptions_to_update[subscription.id] = subscription
|
||||
|
||||
for squad_uuid in removed_uuids:
|
||||
if not squad_uuid:
|
||||
continue
|
||||
|
||||
extra_result = await db.execute(
|
||||
select(Subscription).where(
|
||||
text("connected_squads::text LIKE :uuid_pattern")
|
||||
),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
|
||||
for subscription in extra_result.scalars().unique().all():
|
||||
subscriptions_to_update[subscription.id] = subscription
|
||||
|
||||
cleaned_subscriptions = 0
|
||||
|
||||
for subscription in subscriptions_to_update.values():
|
||||
current_squads = list(subscription.connected_squads or [])
|
||||
if not current_squads:
|
||||
continue
|
||||
|
||||
filtered_squads = [
|
||||
squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
|
||||
]
|
||||
|
||||
if len(filtered_squads) != len(current_squads):
|
||||
subscription.connected_squads = filtered_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
cleaned_subscriptions += 1
|
||||
|
||||
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
|
||||
removed = len(removed_servers)
|
||||
|
||||
if cleaned_subscriptions:
|
||||
logger.info(
|
||||
"🧹 Обновлены подписки после удаления серверов: %s",
|
||||
cleaned_subscriptions,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
|
||||
return created, updated, disabled
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
|
||||
return created, updated, removed
|
||||
|
||||
|
||||
async def get_server_connected_users(
|
||||
db: AsyncSession,
|
||||
server_id: int
|
||||
) -> List[User]:
|
||||
|
||||
server_uuid_result = await db.execute(
|
||||
select(ServerSquad.squad_uuid).where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_uuid = server_uuid_result.scalar_one_or_none()
|
||||
|
||||
connection_filters = [SubscriptionServer.id.isnot(None)]
|
||||
|
||||
if server_uuid:
|
||||
connection_filters.append(
|
||||
cast(Subscription.connected_squads, String).like(
|
||||
f'%"{server_uuid}"%'
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(Subscription, Subscription.user_id == User.id)
|
||||
.outerjoin(
|
||||
SubscriptionServer,
|
||||
and_(
|
||||
SubscriptionServer.subscription_id == Subscription.id,
|
||||
SubscriptionServer.server_squad_id == server_id,
|
||||
),
|
||||
)
|
||||
.where(or_(*connection_filters))
|
||||
.options(selectinload(User.subscription))
|
||||
.order_by(User.id)
|
||||
)
|
||||
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_trial_eligible_server_squads(
|
||||
db: AsyncSession,
|
||||
include_unavailable: bool = False,
|
||||
) -> List[ServerSquad]:
|
||||
|
||||
query = select(ServerSquad).where(ServerSquad.is_trial_eligible.is_(True))
|
||||
|
||||
result = await db.execute(query)
|
||||
squads = result.scalars().unique().all()
|
||||
|
||||
if include_unavailable:
|
||||
return squads
|
||||
|
||||
preferred_squads: List[ServerSquad] = []
|
||||
fallback_squads: List[ServerSquad] = []
|
||||
|
||||
for squad in squads:
|
||||
current_users = squad.current_users or 0
|
||||
is_full = squad.max_users is not None and current_users >= squad.max_users
|
||||
|
||||
if is_full:
|
||||
continue
|
||||
|
||||
if squad.is_available:
|
||||
preferred_squads.append(squad)
|
||||
else:
|
||||
fallback_squads.append(squad)
|
||||
|
||||
if preferred_squads:
|
||||
return preferred_squads
|
||||
|
||||
if fallback_squads:
|
||||
return fallback_squads
|
||||
|
||||
return squads
|
||||
|
||||
|
||||
async def choose_random_trial_server_squad(
|
||||
db: AsyncSession,
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
squads = await get_trial_eligible_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return None
|
||||
|
||||
return random.choice(squads)
|
||||
|
||||
|
||||
async def get_random_trial_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
|
||||
squad = await choose_random_trial_server_squad(db)
|
||||
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
|
||||
country_names = {
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from typing import Iterable, Optional, List, Tuple
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -47,10 +47,31 @@ async def create_trial_subscription(
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
|
||||
|
||||
if not squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
squad_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
|
||||
if squad_uuid:
|
||||
logger.debug(
|
||||
"Выбран сквад %s для триальной подписки пользователя %s",
|
||||
squad_uuid,
|
||||
user_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось получить сквад для триальной подписки пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
squad_uuid = settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -59,14 +80,43 @@ async def create_trial_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=[squad_uuid] if squad_uuid else []
|
||||
connected_squads=[squad_uuid] if squad_uuid else [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
|
||||
|
||||
if squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
add_user_to_servers,
|
||||
)
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, [squad_uuid])
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
"📈 Обновлен счетчик пользователей для триального сквада %s",
|
||||
squad_uuid,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)",
|
||||
squad_uuid,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s",
|
||||
squad_uuid,
|
||||
error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -74,9 +124,10 @@ async def create_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
@@ -89,7 +140,9 @@ async def create_paid_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or []
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
@@ -97,6 +150,36 @@ async def create_paid_subscription(
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
|
||||
|
||||
squad_uuids = list(connected_squads or [])
|
||||
if update_server_counters and squad_uuids:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
add_user_to_servers,
|
||||
)
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, squad_uuids)
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
"📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)",
|
||||
user_id,
|
||||
squad_uuids,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)",
|
||||
user_id,
|
||||
squad_uuids,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -110,19 +193,46 @@ async def extend_subscription(
|
||||
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
|
||||
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
if days < 0:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(
|
||||
"📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s",
|
||||
abs(days),
|
||||
subscription.end_date,
|
||||
)
|
||||
elif subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
|
||||
if subscription.is_trial:
|
||||
start_date = subscription.start_date or current_time
|
||||
total_duration = subscription.end_date - start_date
|
||||
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
|
||||
|
||||
if total_duration > max_trial_duration:
|
||||
subscription.is_trial = False
|
||||
logger.info(
|
||||
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
|
||||
", итоговая длительность: %s дней",
|
||||
subscription.id,
|
||||
total_duration.days,
|
||||
)
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value and days > 0:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await clear_notifications(db, subscription.id)
|
||||
@@ -203,6 +313,64 @@ async def remove_subscription_squad(
|
||||
return subscription
|
||||
|
||||
|
||||
async def decrement_subscription_server_counts(
|
||||
db: AsyncSession,
|
||||
subscription: Optional[Subscription],
|
||||
*,
|
||||
subscription_servers: Optional[Iterable[SubscriptionServer]] = None,
|
||||
) -> None:
|
||||
"""Decrease server counters linked to the provided subscription."""
|
||||
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
server_ids: set[int] = set()
|
||||
|
||||
if subscription_servers is not None:
|
||||
for sub_server in subscription_servers:
|
||||
if sub_server and sub_server.server_squad_id is not None:
|
||||
server_ids.add(sub_server.server_squad_id)
|
||||
else:
|
||||
try:
|
||||
ids_from_links = await get_subscription_server_ids(db, subscription.id)
|
||||
server_ids.update(ids_from_links)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
connected_squads = list(subscription.connected_squads or [])
|
||||
if connected_squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
|
||||
squad_server_ids = await get_server_ids_by_uuids(db, connected_squads)
|
||||
server_ids.update(squad_server_ids)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
if not server_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import remove_user_from_servers
|
||||
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s",
|
||||
list(server_ids),
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
async def update_subscription_autopay(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
@@ -531,7 +699,15 @@ async def calculate_subscription_total_cost(
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
@@ -577,6 +753,9 @@ async def calculate_subscription_total_cost(
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'base_price_original': base_price_original,
|
||||
'base_discount_percent': period_discount_percent,
|
||||
'base_discount_total': base_discount_total,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'traffic_discount_percent': traffic_discount_percent,
|
||||
'traffic_discount_total': total_traffic_discount,
|
||||
@@ -954,7 +1133,10 @@ async def create_subscription(
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = ""
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = "",
|
||||
autopay_enabled: Optional[bool] = None,
|
||||
autopay_days_before: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
|
||||
if end_date is None:
|
||||
@@ -973,7 +1155,18 @@ async def create_subscription(
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
autopay_enabled=(
|
||||
settings.is_autopay_enabled_by_default()
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
),
|
||||
autopay_days_before=(
|
||||
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
),
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
|
||||
async def upsert_system_setting(
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
value: Optional[str],
|
||||
description: Optional[str] = None,
|
||||
) -> SystemSetting:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = SystemSetting(key=key, value=value, description=description)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.value = value
|
||||
if description is not None:
|
||||
setting.description = description
|
||||
|
||||
await db.flush()
|
||||
return setting
|
||||
|
||||
|
||||
async def delete_system_setting(db: AsyncSession, key: str) -> None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting is not None:
|
||||
await db.delete(setting)
|
||||
await db.flush()
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_, or_, update, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
|
||||
|
||||
|
||||
class TicketCRUD:
|
||||
"""CRUD операции для работы с тикетами"""
|
||||
|
||||
@staticmethod
|
||||
async def create_ticket(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message_text: str,
|
||||
priority: str = "normal",
|
||||
*,
|
||||
media_type: Optional[str] = None,
|
||||
media_file_id: Optional[str] = None,
|
||||
media_caption: Optional[str] = None,
|
||||
) -> Ticket:
|
||||
"""Создать новый тикет с первым сообщением"""
|
||||
ticket = Ticket(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
status=TicketStatus.OPEN.value,
|
||||
priority=priority
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush() # Получаем ID тикета
|
||||
|
||||
# Создаем первое сообщение
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=False,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_by_id(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
load_messages: bool = True,
|
||||
load_user: bool = False
|
||||
) -> Optional[Ticket]:
|
||||
"""Получить тикет по ID"""
|
||||
query = select(Ticket).where(Ticket.id == ticket_id)
|
||||
|
||||
if load_user:
|
||||
query = query.options(selectinload(Ticket.user))
|
||||
|
||||
if load_messages:
|
||||
query = query.options(selectinload(Ticket.messages))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tickets(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить тикеты пользователя"""
|
||||
query = select(Ticket).where(Ticket.user_id == user_id)
|
||||
|
||||
if status:
|
||||
query = query.where(Ticket.status == status)
|
||||
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_user_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
statuses: List[str]
|
||||
) -> int:
|
||||
"""Подсчитать количество тикетов пользователя по списку статусов"""
|
||||
query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
statuses: List[str],
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить тикеты пользователя по списку статусов с пагинацией"""
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.user_id == user_id)
|
||||
.order_by(desc(Ticket.updated_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def user_has_active_ticket(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
"""Проверить, есть ли у пользователя активный (не закрытый) тикет"""
|
||||
query = (
|
||||
select(Ticket.id)
|
||||
.where(
|
||||
Ticket.user_id == user_id,
|
||||
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@staticmethod
|
||||
async def is_user_globally_blocked(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[datetime]:
|
||||
"""Проверить, заблокирован ли пользователь для создания/ответов по любому тикету.
|
||||
Возвращает дату окончания блокировки, если активна, или None.
|
||||
"""
|
||||
query = select(Ticket).where(
|
||||
Ticket.user_id == user_id,
|
||||
or_(Ticket.user_reply_block_permanent == True, Ticket.user_reply_block_until.isnot(None))
|
||||
).order_by(desc(Ticket.updated_at)).limit(10)
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
if not tickets:
|
||||
return None
|
||||
from datetime import datetime
|
||||
# Если есть вечная блокировка в любом тикете — блок активен без срока
|
||||
for t in tickets:
|
||||
if t.user_reply_block_permanent:
|
||||
return datetime.max
|
||||
# Иначе ищем максимальный срок блокировки, если он в будущем
|
||||
future_until = [t.user_reply_block_until for t in tickets if t.user_reply_block_until]
|
||||
if not future_until:
|
||||
return None
|
||||
max_until = max(future_until)
|
||||
return max_until if max_until > datetime.utcnow() else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_tickets(
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить все тикеты (для админов)"""
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
|
||||
conditions = []
|
||||
if status:
|
||||
conditions.append(Ticket.status == status)
|
||||
if priority:
|
||||
conditions.append(Ticket.priority == priority)
|
||||
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
statuses: List[str],
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_tickets(
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None
|
||||
) -> int:
|
||||
query = select(func.count()).select_from(Ticket)
|
||||
if status:
|
||||
query = query.where(Ticket.status == status)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def count_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
statuses: List[str]
|
||||
) -> int:
|
||||
query = select(func.count()).select_from(Ticket)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def update_ticket_status(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
status: str,
|
||||
closed_at: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""Обновить статус тикета"""
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
return False
|
||||
|
||||
ticket.status = status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
if status == TicketStatus.CLOSED.value and closed_at:
|
||||
ticket.closed_at = closed_at
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def set_user_reply_block(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
permanent: bool,
|
||||
until: Optional[datetime]
|
||||
) -> bool:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
return False
|
||||
ticket.user_reply_block_permanent = bool(permanent)
|
||||
ticket.user_reply_block_until = until
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def close_ticket(
|
||||
db: AsyncSession,
|
||||
ticket_id: int
|
||||
) -> bool:
|
||||
"""Закрыть тикет"""
|
||||
return await TicketCRUD.update_ticket_status(
|
||||
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def add_support_audit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
actor_user_id: Optional[int],
|
||||
actor_telegram_id: int,
|
||||
is_moderator: bool,
|
||||
action: str,
|
||||
ticket_id: Optional[int] = None,
|
||||
target_user_id: Optional[int] = None,
|
||||
details: Optional[dict] = None,
|
||||
) -> None:
|
||||
try:
|
||||
log = SupportAuditLog(
|
||||
actor_user_id=actor_user_id,
|
||||
actor_telegram_id=actor_telegram_id,
|
||||
is_moderator=bool(is_moderator),
|
||||
action=action,
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=target_user_id,
|
||||
details=details or {},
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
# не мешаем основной логике
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def list_support_audit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[SupportAuditLog]:
|
||||
from sqlalchemy import select, desc
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_support_audit(db: AsyncSession) -> int:
|
||||
from sqlalchemy import select, func
|
||||
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def get_open_tickets_count(db: AsyncSession) -> int:
|
||||
"""Получить количество открытых тикетов"""
|
||||
query = select(Ticket).where(Ticket.status.in_([
|
||||
TicketStatus.OPEN.value,
|
||||
TicketStatus.ANSWERED.value
|
||||
]))
|
||||
result = await db.execute(query)
|
||||
return len(result.scalars().all())
|
||||
|
||||
|
||||
class TicketMessageCRUD:
|
||||
"""CRUD операции для работы с сообщениями тикетов"""
|
||||
|
||||
@staticmethod
|
||||
async def add_message(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
user_id: int,
|
||||
message_text: str,
|
||||
is_from_admin: bool = False,
|
||||
media_type: Optional[str] = None,
|
||||
media_file_id: Optional[str] = None,
|
||||
media_caption: Optional[str] = None
|
||||
) -> TicketMessage:
|
||||
"""Добавить сообщение в тикет"""
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket_id,
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=is_from_admin,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
|
||||
# Обновляем статус тикета
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if ticket:
|
||||
# Если тикет закрыт, запрещаем изменение статуса при сообщении пользователя
|
||||
if not is_from_admin and ticket.status == TicketStatus.CLOSED.value:
|
||||
return message
|
||||
if is_from_admin:
|
||||
# Админ ответил - тикет отвечен
|
||||
ticket.status = TicketStatus.ANSWERED.value
|
||||
else:
|
||||
# Пользователь ответил - тикет открыт
|
||||
ticket.status = TicketStatus.OPEN.value
|
||||
# Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения
|
||||
try:
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
# если колонка существует в модели
|
||||
if hasattr(ticket, 'last_sla_reminder_at'):
|
||||
ticket.last_sla_reminder_at = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_messages(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[TicketMessage]:
|
||||
"""Получить сообщения тикета"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(TicketMessage.created_at)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_last_message(
|
||||
db: AsyncSession,
|
||||
ticket_id: int
|
||||
) -> Optional[TicketMessage]:
|
||||
"""Получить последнее сообщение в тикете"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(desc(TicketMessage.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -37,6 +37,20 @@ async def create_transaction(
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
maybe_assign_promo_group_by_total_spent,
|
||||
)
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
@@ -98,15 +112,42 @@ async def get_user_transactions_count(
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
|
||||
|
||||
|
||||
transaction.is_completed = True
|
||||
transaction.completed_at = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
|
||||
logger.info(f"✅ Транзакция {transaction.id} завершена")
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
maybe_assign_promo_group_by_total_spent,
|
||||
)
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
|
||||
transaction.user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
|
||||
+464
-50
@@ -2,14 +2,27 @@ import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from typing import Optional, List, Dict
|
||||
from sqlalchemy import select, and_, or_, func, case, nullslast, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup
|
||||
from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
PaymentMethod,
|
||||
TransactionType,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,6 +89,37 @@ async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
async def _sync_users_sequence(db: AsyncSession) -> None:
|
||||
"""Ensure the users.id sequence matches the current max ID."""
|
||||
await db.execute(
|
||||
text(
|
||||
"SELECT setval('users_id_seq', "
|
||||
"COALESCE((SELECT MAX(id) FROM users), 0) + 1, false)"
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logger.warning(
|
||||
"🔄 Последовательность users_id_seq была синхронизирована с текущим максимумом id"
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
|
||||
default_group = await get_default_promo_group(db)
|
||||
if default_group:
|
||||
return default_group
|
||||
|
||||
default_group = PromoGroup(
|
||||
name="Базовый юзер",
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
return default_group
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
@@ -88,47 +132,63 @@ async def create_user(
|
||||
) -> User:
|
||||
|
||||
if not referral_code:
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
default_group = PromoGroup(
|
||||
name="Базовый юзер",
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
attempts = 3
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
|
||||
safe_first = sanitize_telegram_name(first_name)
|
||||
safe_last = sanitize_telegram_name(last_name)
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
|
||||
promo_group_id = default_group.id
|
||||
db.add(user)
|
||||
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if default_group:
|
||||
user.promo_group = default_group
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
user.promo_group = default_group
|
||||
logger.info(
|
||||
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
|
||||
)
|
||||
return user
|
||||
|
||||
return user
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
|
||||
if (
|
||||
isinstance(getattr(exc, "orig", None), Exception)
|
||||
and "users_pkey" in str(exc.orig)
|
||||
and attempt < attempts
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. "
|
||||
"Выполняем повторную синхронизацию (попытка %s/%s)",
|
||||
telegram_id,
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
await _sync_users_sequence(db)
|
||||
continue
|
||||
|
||||
raise
|
||||
|
||||
raise RuntimeError("Не удалось создать пользователя после синхронизации последовательности")
|
||||
|
||||
|
||||
async def update_user(
|
||||
@@ -137,7 +197,10 @@ async def update_user(
|
||||
**kwargs
|
||||
) -> User:
|
||||
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
for field, value in kwargs.items():
|
||||
if field in ("first_name", "last_name"):
|
||||
value = sanitize_telegram_name(value)
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -206,10 +269,14 @@ async def add_user_balance_by_id(
|
||||
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
create_transaction: bool = False,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
*,
|
||||
consume_promo_offer: bool = False,
|
||||
) -> bool:
|
||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
@@ -217,18 +284,100 @@ async def subtract_user_balance(
|
||||
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||
logger.error(f" 📝 Описание: {description}")
|
||||
|
||||
log_context: Optional[Dict[str, object]] = None
|
||||
if consume_promo_offer:
|
||||
try:
|
||||
current_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
current_percent = 0
|
||||
|
||||
if current_percent > 0:
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
log_context = {
|
||||
"offer_id": None,
|
||||
"percent": current_percent,
|
||||
"source": source,
|
||||
"effect_type": None,
|
||||
"details": {
|
||||
"reason": "manual_charge",
|
||||
"description": description,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
},
|
||||
}
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception as lookup_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to fetch latest claimed promo offer for user %s: %s",
|
||||
user.id,
|
||||
lookup_error,
|
||||
)
|
||||
offer = None
|
||||
|
||||
if offer:
|
||||
log_context["offer_id"] = offer.id
|
||||
log_context["effect_type"] = offer.effect_type
|
||||
if not log_context["percent"] and offer.discount_percent:
|
||||
log_context["percent"] = offer.discount_percent
|
||||
|
||||
if user.balance_kopeks < amount_kopeks:
|
||||
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks -= amount_kopeks
|
||||
|
||||
if consume_promo_offer and getattr(user, "promo_offer_discount_percent", 0):
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.WITHDRAWAL,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
offer_id=log_context.get("offer_id"),
|
||||
action="consumed",
|
||||
source=log_context.get("source"),
|
||||
percent=log_context.get("percent"),
|
||||
effect_type=log_context.get("effect_type"),
|
||||
details=log_context.get("details"),
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer consumption log for user %s: %s",
|
||||
user.id,
|
||||
log_error,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer consumption log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
logger.error(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}")
|
||||
return True
|
||||
|
||||
@@ -238,13 +387,108 @@ async def subtract_user_balance(
|
||||
return False
|
||||
|
||||
|
||||
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.promo_offer_discount_percent > 0,
|
||||
User.promo_offer_discount_expires_at.isnot(None),
|
||||
User.promo_offer_discount_expires_at <= now,
|
||||
)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
if not users:
|
||||
return 0
|
||||
|
||||
log_payloads: List[Dict[str, object]] = []
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
percent = 0
|
||||
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
offer_id = None
|
||||
effect_type = None
|
||||
|
||||
if source:
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception as lookup_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s",
|
||||
user.id,
|
||||
lookup_error,
|
||||
)
|
||||
offer = None
|
||||
|
||||
if offer:
|
||||
offer_id = offer.id
|
||||
effect_type = offer.effect_type
|
||||
if not percent and offer.discount_percent:
|
||||
percent = offer.discount_percent
|
||||
|
||||
log_payloads.append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"offer_id": offer_id,
|
||||
"source": source,
|
||||
"percent": percent,
|
||||
"effect_type": effect_type,
|
||||
}
|
||||
)
|
||||
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
|
||||
for payload in log_payloads:
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
continue
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user_id,
|
||||
offer_id=payload.get("offer_id"),
|
||||
action="disabled",
|
||||
source=payload.get("source"),
|
||||
percent=payload.get("percent"),
|
||||
effect_type=payload.get("effect_type"),
|
||||
details={"reason": "offer_expired"},
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to log promo offer expiration for user %s: %s",
|
||||
user_id,
|
||||
log_error,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer expiration log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
return len(users)
|
||||
|
||||
|
||||
async def get_users_list(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[UserStatus] = None,
|
||||
order_by_balance: bool = False
|
||||
order_by_balance: bool = False,
|
||||
order_by_traffic: bool = False,
|
||||
order_by_last_activity: bool = False,
|
||||
order_by_total_spent: bool = False,
|
||||
order_by_purchase_count: bool = False
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
@@ -264,10 +508,71 @@ async def get_users_list(
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
# Сортировка по балансу в порядке убывания, если order_by_balance=True
|
||||
if order_by_balance:
|
||||
query = query.order_by(User.balance_kopeks.desc())
|
||||
|
||||
sort_flags = [
|
||||
order_by_balance,
|
||||
order_by_traffic,
|
||||
order_by_last_activity,
|
||||
order_by_total_spent,
|
||||
order_by_purchase_count,
|
||||
]
|
||||
if sum(int(flag) for flag in sort_flags) > 1:
|
||||
logger.debug(
|
||||
"Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность"
|
||||
)
|
||||
|
||||
transactions_stats = None
|
||||
if order_by_total_spent or order_by_purchase_count:
|
||||
from app.database.models import Transaction
|
||||
|
||||
transactions_stats = (
|
||||
select(
|
||||
Transaction.user_id.label("user_id"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("total_spent"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("purchase_count"),
|
||||
)
|
||||
.where(Transaction.is_completed.is_(True))
|
||||
.group_by(Transaction.user_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.outerjoin(transactions_stats, transactions_stats.c.user_id == User.id)
|
||||
|
||||
if order_by_traffic:
|
||||
traffic_sort = func.coalesce(Subscription.traffic_used_gb, 0.0)
|
||||
query = query.outerjoin(Subscription, Subscription.user_id == User.id)
|
||||
query = query.order_by(traffic_sort.desc(), User.created_at.desc())
|
||||
elif order_by_total_spent:
|
||||
order_column = func.coalesce(transactions_stats.c.total_spent, 0)
|
||||
query = query.order_by(order_column.desc(), User.created_at.desc())
|
||||
elif order_by_purchase_count:
|
||||
order_column = func.coalesce(transactions_stats.c.purchase_count, 0)
|
||||
query = query.order_by(order_column.desc(), User.created_at.desc())
|
||||
elif order_by_balance:
|
||||
query = query.order_by(User.balance_kopeks.desc(), User.created_at.desc())
|
||||
elif order_by_last_activity:
|
||||
query = query.order_by(nullslast(User.last_activity.desc()), User.created_at.desc())
|
||||
else:
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
@@ -305,6 +610,62 @@ async def get_users_count(
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_users_spending_stats(
|
||||
db: AsyncSession,
|
||||
user_ids: List[int]
|
||||
) -> Dict[int, Dict[str, int]]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
from app.database.models import Transaction
|
||||
|
||||
stats_query = (
|
||||
select(
|
||||
Transaction.user_id,
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("total_spent"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("purchase_count"),
|
||||
)
|
||||
.where(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
|
||||
result = await db.execute(stats_query)
|
||||
rows = result.all()
|
||||
|
||||
return {
|
||||
row.user_id: {
|
||||
"total_spent": int(row.total_spent or 0),
|
||||
"purchase_count": int(row.purchase_count or 0),
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
@@ -318,6 +679,59 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
|
||||
now = datetime.utcnow()
|
||||
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
|
||||
if segment == "no_subscription":
|
||||
query = (
|
||||
base_query.outerjoin(Subscription, Subscription.user_id == User.id)
|
||||
.where(Subscription.id.is_(None))
|
||||
)
|
||||
else:
|
||||
query = base_query.join(Subscription)
|
||||
|
||||
if segment == "paid_active":
|
||||
query = query.where(
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
elif segment == "paid_expired":
|
||||
query = query.where(
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
or_(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date <= now,
|
||||
),
|
||||
)
|
||||
elif segment == "trial_active":
|
||||
query = query.where(
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
elif segment == "trial_expired":
|
||||
query = query.where(
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
or_(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date <= now,
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.warning("Неизвестный сегмент для промо: %s", segment)
|
||||
return []
|
||||
|
||||
result = await db.execute(query.order_by(User.id))
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""CRUD операции для токенов административного веб-API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WebApiToken
|
||||
|
||||
|
||||
async def list_tokens(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> List[WebApiToken]:
|
||||
query = select(WebApiToken)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(WebApiToken.is_active.is_(True))
|
||||
|
||||
query = query.order_by(WebApiToken.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
|
||||
return await db.get(WebApiToken, token_id)
|
||||
|
||||
|
||||
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
|
||||
query = select(WebApiToken).where(
|
||||
WebApiToken.token_hash == token_hash
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_token(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> WebApiToken:
|
||||
token = WebApiToken(
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=token_prefix,
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(token)
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def update_token(
|
||||
db: AsyncSession,
|
||||
token: WebApiToken,
|
||||
**kwargs,
|
||||
) -> WebApiToken:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(token, key):
|
||||
setattr(token, key, value)
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def set_tokens_active_status(
|
||||
db: AsyncSession,
|
||||
token_ids: Iterable[int],
|
||||
*,
|
||||
is_active: bool,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
update(WebApiToken)
|
||||
.where(WebApiToken.id.in_(list(token_ids)))
|
||||
.values(is_active=is_active, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
|
||||
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
|
||||
await db.delete(token)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"list_tokens",
|
||||
"get_token_by_id",
|
||||
"get_token_by_hash",
|
||||
"create_token",
|
||||
"update_token",
|
||||
"set_tokens_active_status",
|
||||
"delete_token",
|
||||
]
|
||||
@@ -10,7 +10,7 @@ from app.database.models import Base
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
settings.get_database_url(),
|
||||
poolclass=NullPool,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
@@ -48,4 +48,4 @@ async def init_db():
|
||||
|
||||
async def close_db():
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
|
||||
+546
-27
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -14,6 +14,8 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
Table,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
@@ -23,6 +25,24 @@ from sqlalchemy.sql import func
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
server_squad_promo_groups = Table(
|
||||
"server_squad_promo_groups",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"server_squad_id",
|
||||
Integer,
|
||||
ForeignKey("server_squads.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
"promo_group_id",
|
||||
Integer,
|
||||
ForeignKey("promo_groups.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UserStatus(Enum):
|
||||
ACTIVE = "active"
|
||||
BLOCKED = "blocked"
|
||||
@@ -53,9 +73,22 @@ class PromoCodeType(Enum):
|
||||
class PaymentMethod(Enum):
|
||||
TELEGRAM_STARS = "telegram_stars"
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
YOOKASSA = "yookassa"
|
||||
CRYPTOBOT = "cryptobot"
|
||||
MANUAL = "manual"
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class MainMenuButtonActionType(Enum):
|
||||
URL = "url"
|
||||
MINI_APP = "mini_app"
|
||||
|
||||
|
||||
class MainMenuButtonVisibility(Enum):
|
||||
ALL = "all"
|
||||
ADMINS = "admins"
|
||||
SUBSCRIBERS = "subscribers"
|
||||
|
||||
class YooKassaPayment(Base):
|
||||
__tablename__ = "yookassa_payments"
|
||||
@@ -107,7 +140,7 @@ class YooKassaPayment(Base):
|
||||
|
||||
class CryptoBotPayment(Base):
|
||||
__tablename__ = "cryptobot_payments"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
@@ -155,6 +188,111 @@ class CryptoBotPayment(Base):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class MulenPayPayment(Base):
|
||||
__tablename__ = "mulenpay_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
mulen_payment_id = Column(Integer, nullable=True, index=True)
|
||||
uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
status = Column(String(50), nullable=False, default="created")
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
payment_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="mulenpay_payments")
|
||||
transaction = relationship("Transaction", backref="mulenpay_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<MulenPayPayment(id={0}, mulen_id={1}, amount={2}₽, status={3})>".format(
|
||||
self.id,
|
||||
self.mulen_payment_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Pal24Payment(Base):
|
||||
__tablename__ = "pal24_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
bill_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
order_id = Column(String(255), nullable=True, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
type = Column(String(20), nullable=False, default="normal")
|
||||
|
||||
status = Column(String(50), nullable=False, default="NEW")
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
last_status = Column(String(50), nullable=True)
|
||||
last_status_checked_at = Column(DateTime, nullable=True)
|
||||
|
||||
link_url = Column(Text, nullable=True)
|
||||
link_page_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
payment_id = Column(String(255), nullable=True, index=True)
|
||||
payment_status = Column(String(50), nullable=True)
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
balance_amount = Column(String(50), nullable=True)
|
||||
balance_currency = Column(String(10), nullable=True)
|
||||
payer_account = Column(String(255), nullable=True)
|
||||
|
||||
ttl = Column(Integer, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="pal24_payments")
|
||||
transaction = relationship("Transaction", backref="pal24_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status in {"NEW", "PROCESS"}
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<Pal24Payment(id={0}, bill_id={1}, amount={2}₽, status={3})>".format(
|
||||
self.id,
|
||||
self.bill_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
@@ -163,13 +301,67 @@ class PromoGroup(Base):
|
||||
server_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
period_discounts = Column(JSON, nullable=True, default=dict)
|
||||
auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None)
|
||||
apply_discounts_to_addons = Column(Boolean, nullable=False, default=True)
|
||||
is_default = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
users = relationship("User", back_populates="promo_group")
|
||||
server_squads = relationship(
|
||||
"ServerSquad",
|
||||
secondary=server_squad_promo_groups,
|
||||
back_populates="allowed_promo_groups",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def _get_period_discounts_map(self) -> Dict[int, int]:
|
||||
raw_discounts = self.period_discounts or {}
|
||||
|
||||
if isinstance(raw_discounts, dict):
|
||||
items = raw_discounts.items()
|
||||
else:
|
||||
items = []
|
||||
|
||||
normalized: Dict[int, int] = {}
|
||||
|
||||
for key, value in items:
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized[period] = max(0, min(100, percent))
|
||||
|
||||
return normalized
|
||||
|
||||
def _get_period_discount(self, period_days: Optional[int]) -> int:
|
||||
if not period_days:
|
||||
return 0
|
||||
|
||||
discounts = self._get_period_discounts_map()
|
||||
|
||||
if period_days in discounts:
|
||||
return discounts[period_days]
|
||||
|
||||
if self.is_default:
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_base_promo_group_period_discount_enabled():
|
||||
config_discounts = settings.get_base_promo_group_period_discounts()
|
||||
return config_discounts.get(period_days, 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
if category == "period":
|
||||
return max(0, min(100, self._get_period_discount(period_days)))
|
||||
|
||||
mapping = {
|
||||
"servers": self.server_discount_percent,
|
||||
"traffic": self.traffic_discount_percent,
|
||||
@@ -177,23 +369,12 @@ class PromoGroup(Base):
|
||||
}
|
||||
percent = mapping.get(category, 0)
|
||||
|
||||
if self.is_default and period_days is not None:
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
discounts = settings.get_base_promo_group_period_discounts()
|
||||
if period_days in discounts:
|
||||
period_discount = discounts[period_days]
|
||||
percent = period_discount
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
|
||||
username = Column(String(255), nullable=True)
|
||||
@@ -215,7 +396,14 @@ class User(Base):
|
||||
subscription = relationship("Subscription", back_populates="user", uselist=False)
|
||||
transactions = relationship("Transaction", back_populates="user")
|
||||
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
|
||||
discount_offers = relationship("DiscountOffer", back_populates="user")
|
||||
promo_offer_logs = relationship("PromoOfferLog", back_populates="user")
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
||||
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
|
||||
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
|
||||
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
promo_offer_discount_source = Column(String(100), nullable=True)
|
||||
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
|
||||
last_remnawave_sync = Column(DateTime, nullable=True)
|
||||
trojan_password = Column(String(255), nullable=True)
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
@@ -264,7 +452,8 @@ class Subscription(Base):
|
||||
traffic_used_gb = Column(Float, default=0.0)
|
||||
|
||||
subscription_url = Column(String, nullable=True)
|
||||
|
||||
subscription_crypto_link = Column(String, nullable=True)
|
||||
|
||||
device_limit = Column(Integer, default=1)
|
||||
|
||||
connected_squads = Column(JSON, default=list)
|
||||
@@ -276,8 +465,10 @@ class Subscription(Base):
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
user = relationship("User", back_populates="subscription")
|
||||
discount_offers = relationship("DiscountOffer", back_populates="subscription")
|
||||
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
@@ -396,8 +587,7 @@ class Subscription(Base):
|
||||
return 0.0
|
||||
|
||||
def extend_subscription(self, days: int):
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
|
||||
if self.end_date > datetime.utcnow():
|
||||
self.end_date = self.end_date + timedelta(days=days)
|
||||
else:
|
||||
@@ -565,9 +755,9 @@ class Squad(Base):
|
||||
|
||||
class ServiceRule(Base):
|
||||
__tablename__ = "service_rules"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
|
||||
order = Column(Integer, default=0)
|
||||
title = Column(String(255), nullable=False)
|
||||
|
||||
@@ -581,9 +771,54 @@ class ServiceRule(Base):
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class PrivacyPolicy(Base):
|
||||
__tablename__ = "privacy_policies"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, unique=True)
|
||||
content = Column(Text, nullable=False)
|
||||
is_enabled = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class PublicOffer(Base):
|
||||
__tablename__ = "public_offers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, unique=True)
|
||||
content = Column(Text, nullable=False)
|
||||
is_enabled = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class FaqSetting(Base):
|
||||
__tablename__ = "faq_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, unique=True)
|
||||
is_enabled = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class FaqPage(Base):
|
||||
__tablename__ = "faq_pages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
display_order = Column(Integer, default=0, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(255), unique=True, nullable=False)
|
||||
value = Column(Text, nullable=True)
|
||||
@@ -621,6 +856,90 @@ class SentNotification(Base):
|
||||
user = relationship("User", backref="sent_notifications")
|
||||
subscription = relationship("Subscription", backref="sent_notifications")
|
||||
|
||||
|
||||
class DiscountOffer(Base):
|
||||
__tablename__ = "discount_offers"
|
||||
__table_args__ = (
|
||||
Index("ix_discount_offers_user_type", "user_id", "notification_type"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True)
|
||||
notification_type = Column(String(50), nullable=False)
|
||||
discount_percent = Column(Integer, nullable=False, default=0)
|
||||
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
claimed_at = Column(DateTime, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
effect_type = Column(String(50), nullable=False, default="percent_discount")
|
||||
extra_data = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", back_populates="discount_offers")
|
||||
subscription = relationship("Subscription", back_populates="discount_offers")
|
||||
logs = relationship("PromoOfferLog", back_populates="offer")
|
||||
|
||||
|
||||
class PromoOfferTemplate(Base):
|
||||
__tablename__ = "promo_offer_templates"
|
||||
__table_args__ = (
|
||||
Index("ix_promo_offer_templates_type", "offer_type"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
offer_type = Column(String(50), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
button_text = Column(String(255), nullable=False)
|
||||
valid_hours = Column(Integer, nullable=False, default=24)
|
||||
discount_percent = Column(Integer, nullable=False, default=0)
|
||||
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
active_discount_hours = Column(Integer, nullable=True)
|
||||
test_duration_hours = Column(Integer, nullable=True)
|
||||
test_squad_uuids = Column(JSON, default=list)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User")
|
||||
|
||||
|
||||
class SubscriptionTemporaryAccess(Base):
|
||||
__tablename__ = "subscription_temporary_access"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
|
||||
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="CASCADE"), nullable=False)
|
||||
squad_uuid = Column(String(255), nullable=False)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
deactivated_at = Column(DateTime, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
was_already_connected = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
subscription = relationship("Subscription", back_populates="temporary_accesses")
|
||||
offer = relationship("DiscountOffer")
|
||||
|
||||
|
||||
class PromoOfferLog(Base):
|
||||
__tablename__ = "promo_offer_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
action = Column(String(50), nullable=False)
|
||||
source = Column(String(100), nullable=True)
|
||||
percent = Column(Integer, nullable=True)
|
||||
effect_type = Column(String(50), nullable=True)
|
||||
details = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="promo_offer_logs")
|
||||
offer = relationship("DiscountOffer", back_populates="logs")
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
@@ -643,9 +962,9 @@ class BroadcastHistory(Base):
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
|
||||
squad_uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
display_name = Column(String(255), nullable=False)
|
||||
@@ -655,6 +974,7 @@ class ServerSquad(Base):
|
||||
country_code = Column(String(5), nullable=True)
|
||||
|
||||
is_available = Column(Boolean, default=True)
|
||||
is_trial_eligible = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
price_kopeks = Column(Integer, default=0)
|
||||
|
||||
@@ -663,10 +983,17 @@ class ServerSquad(Base):
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
max_users = Column(Integer, nullable=True)
|
||||
current_users = Column(Integer, default=0)
|
||||
|
||||
current_users = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
allowed_promo_groups = relationship(
|
||||
"PromoGroup",
|
||||
secondary=server_squad_promo_groups,
|
||||
back_populates="server_squads",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
@property
|
||||
def price_rubles(self) -> float:
|
||||
@@ -702,6 +1029,23 @@ class SubscriptionServer(Base):
|
||||
subscription = relationship("Subscription", backref="subscription_servers")
|
||||
server_squad = relationship("ServerSquad", backref="subscription_servers")
|
||||
|
||||
|
||||
class SupportAuditLog(Base):
|
||||
__tablename__ = "support_audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
actor_telegram_id = Column(BigInteger, nullable=False)
|
||||
is_moderator = Column(Boolean, default=False)
|
||||
action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user
|
||||
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
|
||||
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
details = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
actor = relationship("User", foreign_keys=[actor_user_id])
|
||||
ticket = relationship("Ticket", foreign_keys=[ticket_id])
|
||||
|
||||
class UserMessage(Base):
|
||||
__tablename__ = "user_messages"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@@ -784,3 +1128,178 @@ class AdvertisingCampaignRegistration(Base):
|
||||
@property
|
||||
def balance_bonus_rubles(self) -> float:
|
||||
return (self.balance_bonus_kopeks or 0) / 100
|
||||
|
||||
|
||||
class TicketStatus(Enum):
|
||||
OPEN = "open"
|
||||
ANSWERED = "answered"
|
||||
CLOSED = "closed"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
class Ticket(Base):
|
||||
__tablename__ = "tickets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
title = Column(String(255), nullable=False)
|
||||
status = Column(String(20), default=TicketStatus.OPEN.value, nullable=False)
|
||||
priority = Column(String(20), default="normal", nullable=False) # low, normal, high, urgent
|
||||
# Блокировка ответов пользователя в этом тикете
|
||||
user_reply_block_permanent = Column(Boolean, default=False, nullable=False)
|
||||
user_reply_block_until = Column(DateTime, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
# SLA reminders
|
||||
last_sla_reminder_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Связи
|
||||
user = relationship("User", backref="tickets")
|
||||
messages = relationship("TicketMessage", back_populates="ticket", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.status == TicketStatus.OPEN.value
|
||||
|
||||
@property
|
||||
def is_answered(self) -> bool:
|
||||
return self.status == TicketStatus.ANSWERED.value
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self.status == TicketStatus.CLOSED.value
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == TicketStatus.PENDING.value
|
||||
|
||||
@property
|
||||
def is_user_reply_blocked(self) -> bool:
|
||||
if self.user_reply_block_permanent:
|
||||
return True
|
||||
if self.user_reply_block_until:
|
||||
try:
|
||||
from datetime import datetime
|
||||
return self.user_reply_block_until > datetime.utcnow()
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
status_emojis = {
|
||||
TicketStatus.OPEN.value: "🔴",
|
||||
TicketStatus.ANSWERED.value: "🟡",
|
||||
TicketStatus.CLOSED.value: "🟢",
|
||||
TicketStatus.PENDING.value: "⏳"
|
||||
}
|
||||
return status_emojis.get(self.status, "❓")
|
||||
|
||||
@property
|
||||
def priority_emoji(self) -> str:
|
||||
priority_emojis = {
|
||||
"low": "🟢",
|
||||
"normal": "🟡",
|
||||
"high": "🟠",
|
||||
"urgent": "🔴"
|
||||
}
|
||||
return priority_emojis.get(self.priority, "🟡")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Ticket(id={self.id}, user_id={self.user_id}, status={self.status}, title='{self.title[:30]}...')>"
|
||||
|
||||
|
||||
class TicketMessage(Base):
|
||||
__tablename__ = "ticket_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
message_text = Column(Text, nullable=False)
|
||||
is_from_admin = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Для медиа файлов
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc.
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
media_caption = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# Связи
|
||||
ticket = relationship("Ticket", back_populates="messages")
|
||||
user = relationship("User")
|
||||
|
||||
@property
|
||||
def is_user_message(self) -> bool:
|
||||
return not self.is_from_admin
|
||||
|
||||
@property
|
||||
def is_admin_message(self) -> bool:
|
||||
return self.is_from_admin
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
||||
|
||||
|
||||
class WebApiToken(Base):
|
||||
__tablename__ = "web_api_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
token_hash = Column(String(128), nullable=False, unique=True, index=True)
|
||||
token_prefix = Column(String(32), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
last_used_ip = Column(String(64), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(String(255), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "revoked"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
|
||||
|
||||
class MainMenuButton(Base):
|
||||
__tablename__ = "main_menu_buttons"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
text = Column(String(64), nullable=False)
|
||||
action_type = Column(String(20), nullable=False)
|
||||
action_value = Column(Text, nullable=False)
|
||||
visibility = Column(String(20), nullable=False, default=MainMenuButtonVisibility.ALL.value)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_main_menu_buttons_order", "display_order", "id"),
|
||||
)
|
||||
|
||||
@property
|
||||
def action_type_enum(self) -> MainMenuButtonActionType:
|
||||
try:
|
||||
return MainMenuButtonActionType(self.action_type)
|
||||
except ValueError:
|
||||
return MainMenuButtonActionType.URL
|
||||
|
||||
@property
|
||||
def visibility_enum(self) -> MainMenuButtonVisibility:
|
||||
try:
|
||||
return MainMenuButtonVisibility(self.visibility)
|
||||
except ValueError:
|
||||
return MainMenuButtonVisibility.ALL
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<MainMenuButton id={self.id} text='{self.text}' "
|
||||
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
|
||||
)
|
||||
|
||||
+2175
-14
File diff suppressed because it is too large
Load Diff
Vendored
+216
@@ -0,0 +1,216 @@
|
||||
"""Async client for PayPalych (Pal24) API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pal24APIError(Exception):
|
||||
"""Base error for Pal24 API operations."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Pal24Response:
|
||||
"""Wrapper for Pal24 API responses."""
|
||||
|
||||
success: bool
|
||||
data: Dict[str, Any]
|
||||
status: int
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Dict[str, Any], status: int) -> "Pal24Response":
|
||||
success = bool(payload.get("success", status < 400))
|
||||
return cls(success=success, data=payload, status=status)
|
||||
|
||||
def raise_for_status(self, endpoint: str) -> None:
|
||||
if not self.success:
|
||||
detail = self.data.get("message") or self.data.get("error")
|
||||
raise Pal24APIError(
|
||||
f"Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}"
|
||||
)
|
||||
|
||||
|
||||
class Pal24Client:
|
||||
"""Async client implementing PayPalych API methods."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_token: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
self.api_token = api_token or settings.PAL24_API_TOKEN
|
||||
self.base_url = (base_url or settings.PAL24_BASE_URL or "").rstrip("/") + "/"
|
||||
self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT
|
||||
|
||||
if not self.api_token:
|
||||
logger.warning("Pal24Client initialized without API token")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.api_token and self.base_url)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_payload: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Response:
|
||||
if not self.is_configured:
|
||||
raise Pal24APIError("Pal24 client is not configured")
|
||||
|
||||
url = f"{self.base_url}{endpoint.lstrip('/')}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_payload,
|
||||
params=params,
|
||||
) as response:
|
||||
status = response.status
|
||||
try:
|
||||
payload = await response.json(content_type=None)
|
||||
except aiohttp.ContentTypeError:
|
||||
text_body = await response.text()
|
||||
logger.error(
|
||||
"Pal24 API returned non-JSON response for %s: %s",
|
||||
endpoint,
|
||||
text_body,
|
||||
)
|
||||
raise Pal24APIError(
|
||||
f"Pal24 API returned non-JSON response: {text_body}"
|
||||
) from None
|
||||
|
||||
result = Pal24Response.from_payload(payload, status)
|
||||
if status >= 400 or not result.success:
|
||||
logger.error(
|
||||
"Pal24 API error %s %s: %s",
|
||||
status,
|
||||
endpoint,
|
||||
payload,
|
||||
)
|
||||
result.raise_for_status(endpoint)
|
||||
|
||||
return result
|
||||
|
||||
except asyncio.TimeoutError as error:
|
||||
logger.error("Pal24 API request timeout for %s: %s", endpoint, error)
|
||||
raise Pal24APIError(f"Pal24 API request timeout for {endpoint}") from error
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("Pal24 API client error for %s: %s", endpoint, error)
|
||||
raise Pal24APIError(str(error)) from error
|
||||
|
||||
# API methods -----------------------------------------------------------------
|
||||
|
||||
async def create_bill(
|
||||
self,
|
||||
*,
|
||||
amount: Decimal,
|
||||
shop_id: str,
|
||||
order_id: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
currency_in: str = "RUB",
|
||||
type_: str = "normal",
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"amount": str(amount),
|
||||
"shop_id": shop_id,
|
||||
"currency_in": currency_in,
|
||||
"type": type_,
|
||||
}
|
||||
|
||||
if order_id:
|
||||
payload["order_id"] = order_id
|
||||
if description:
|
||||
payload["description"] = description
|
||||
|
||||
payload.update({k: v for k, v in kwargs.items() if v is not None})
|
||||
|
||||
response = await self._request("POST", "bill/create", json_payload=payload)
|
||||
return response.data
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/status", params={"id": bill_id})
|
||||
return response.data
|
||||
|
||||
async def toggle_bill_activity(self, bill_id: str, active: bool) -> Dict[str, Any]:
|
||||
payload = {"id": bill_id, "active": 1 if active else 0}
|
||||
response = await self._request("POST", "bill/toggle_activity", json_payload=payload)
|
||||
return response.data
|
||||
|
||||
async def search_payments(self, **params: Any) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "payment/search", params=params)
|
||||
return response.data
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "payment/status", params={"id": payment_id})
|
||||
return response.data
|
||||
|
||||
async def get_balance(self) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "merchant/balance")
|
||||
return response.data
|
||||
|
||||
async def search_bills(self, **params: Any) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/search", params=params)
|
||||
return response.data
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/payments", params={"id": bill_id})
|
||||
return response.data
|
||||
|
||||
# Helpers ---------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def calculate_signature(out_sum: str, inv_id: str, api_token: Optional[str] = None) -> str:
|
||||
token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN
|
||||
if not token:
|
||||
raise Pal24APIError("Pal24 signature token is not configured")
|
||||
raw = f"{out_sum}:{inv_id}:{token}".encode("utf-8")
|
||||
return hashlib.md5(raw).hexdigest().upper()
|
||||
|
||||
@staticmethod
|
||||
def verify_signature(
|
||||
out_sum: str,
|
||||
inv_id: str,
|
||||
signature: str,
|
||||
api_token: Optional[str] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token)
|
||||
except Pal24APIError:
|
||||
logger.error("Pal24 signature verification failed: missing token")
|
||||
return False
|
||||
return expected == signature.upper()
|
||||
|
||||
@staticmethod
|
||||
def normalize_amount(amount_kopeks: int) -> Decimal:
|
||||
try:
|
||||
return (Decimal(amount_kopeks) / Decimal("100")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, TypeError) as error:
|
||||
raise Pal24APIError(f"Invalid amount: {amount_kopeks}") from error
|
||||
|
||||
Vendored
+155
@@ -0,0 +1,155 @@
|
||||
"""Flask webhook server for PayPalych postbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from asyncio import AbstractEventLoop
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_payload() -> Dict[str, str]:
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
logger.warning("Pal24 webhook JSON payload не является объектом: %s", payload)
|
||||
return {}
|
||||
|
||||
if request.form:
|
||||
return {k: v for k, v in request.form.items()}
|
||||
|
||||
try:
|
||||
raw_body = request.data.decode("utf-8")
|
||||
if raw_body:
|
||||
payload = json.loads(raw_body)
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Pal24 webhook body не удалось распарсить как JSON")
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def create_pal24_flask_app(
|
||||
payment_service: PaymentService,
|
||||
loop: AbstractEventLoop,
|
||||
) -> Flask:
|
||||
pal24_service = Pal24Service()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["POST"])
|
||||
def pal24_webhook() -> tuple:
|
||||
if not pal24_service.is_configured:
|
||||
logger.error("Pal24 webhook получен, но сервис не настроен")
|
||||
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning("Пустой Pal24 webhook")
|
||||
return jsonify({"status": "error", "reason": "empty_payload"}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_postback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка валидации Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
|
||||
async def process() -> bool:
|
||||
async for db in get_db():
|
||||
try:
|
||||
return await payment_service.process_pal24_postback(db, parsed_payload)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result()
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": "internal_error"}), 500
|
||||
|
||||
if processed:
|
||||
return jsonify({"status": "ok"}), 200
|
||||
return jsonify({"status": "error", "reason": "not_processed"}), 400
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["GET"])
|
||||
def pal24_health() -> tuple:
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "pal24_webhook",
|
||||
"enabled": settings.is_pal24_enabled(),
|
||||
}), 200
|
||||
|
||||
@app.route("/pal24/health", methods=["GET"])
|
||||
def pal24_additional_health() -> tuple:
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "pal24_webhook",
|
||||
"path": settings.PAL24_WEBHOOK_PATH,
|
||||
}), 200
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 postbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
self._server: Optional[Any] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._server:
|
||||
logger.warning("Pal24 webhook server уже запущен")
|
||||
return
|
||||
|
||||
self._server = make_server(
|
||||
host="0.0.0.0",
|
||||
port=settings.PAL24_WEBHOOK_PORT,
|
||||
app=self.app,
|
||||
threaded=True,
|
||||
)
|
||||
|
||||
def _serve() -> None:
|
||||
logger.info(
|
||||
"Pal24 webhook сервер запущен на %s:%s%s",
|
||||
"0.0.0.0",
|
||||
settings.PAL24_WEBHOOK_PORT,
|
||||
settings.PAL24_WEBHOOK_PATH,
|
||||
)
|
||||
self._server.serve_forever()
|
||||
|
||||
self._thread = threading.Thread(target=_serve, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server:
|
||||
logger.info("Останавливаем Pal24 webhook сервер")
|
||||
self._server.shutdown()
|
||||
self._server = None
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
|
||||
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
|
||||
loop = asyncio.get_running_loop()
|
||||
server = Pal24WebhookServer(payment_service, loop)
|
||||
await loop.run_in_executor(None, server.start)
|
||||
return server
|
||||
|
||||
Vendored
+18
-3
@@ -4,7 +4,6 @@ import ssl
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Union, Any
|
||||
from urllib.parse import urlparse
|
||||
import aiohttp
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
@@ -58,6 +57,8 @@ class RemnaWaveUser:
|
||||
ss_password: Optional[str] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_triggered_threshold: int = 0
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -92,6 +93,8 @@ class SubscriptionInfo:
|
||||
ss_conf_links: Dict[str, str]
|
||||
subscription_url: str
|
||||
happ: Optional[Dict[str, str]]
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
@@ -584,6 +587,10 @@ class RemnaWaveAPI:
|
||||
|
||||
|
||||
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
|
||||
happ_data = user_data.get('happ') or {}
|
||||
happ_link = happ_data.get('link') or happ_data.get('url')
|
||||
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
|
||||
|
||||
return RemnaWaveUser(
|
||||
uuid=user_data['uuid'],
|
||||
short_uuid=user_data['shortUuid'],
|
||||
@@ -612,7 +619,9 @@ class RemnaWaveAPI:
|
||||
vless_uuid=user_data.get('vlessUuid'),
|
||||
ss_password=user_data.get('ssPassword'),
|
||||
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
)
|
||||
|
||||
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
@@ -645,13 +654,19 @@ class RemnaWaveAPI:
|
||||
)
|
||||
|
||||
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
|
||||
happ_data = data.get('happ') or {}
|
||||
happ_link = happ_data.get('link') or happ_data.get('url')
|
||||
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
|
||||
|
||||
return SubscriptionInfo(
|
||||
is_found=data['isFound'],
|
||||
user=data.get('user'),
|
||||
links=data.get('links', []),
|
||||
ss_conf_links=data.get('ssConfLinks', {}),
|
||||
subscription_url=data.get('subscriptionUrl', ''),
|
||||
happ=data.get('happ')
|
||||
happ=data.get('happ'),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
)
|
||||
|
||||
|
||||
|
||||
Vendored
+195
-11
@@ -1,12 +1,17 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
from typing import Optional, Iterable
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.tribute_service import TributeService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,18 +30,25 @@ class WebhookServer:
|
||||
self.app = web.Application()
|
||||
|
||||
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
|
||||
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_post(settings.MULENPAY_WEBHOOK_PATH, self._mulenpay_webhook_handler)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
@@ -54,16 +66,37 @@ class WebhookServer:
|
||||
|
||||
self.site = web.TCPSite(
|
||||
self.runner,
|
||||
host='0.0.0.0',
|
||||
host=settings.TRIBUTE_WEBHOOK_HOST,
|
||||
port=settings.TRIBUTE_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
|
||||
await self.site.start()
|
||||
|
||||
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
|
||||
logger.info(
|
||||
"Webhook сервер запущен на %s:%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"Tribute webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.TRIBUTE_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(
|
||||
"Mulen Pay webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(
|
||||
"CryptoBot webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.CRYPTOBOT_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска webhook сервера: {e}")
|
||||
@@ -89,12 +122,163 @@ class WebhookServer:
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
|
||||
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Пустой Mulen Pay webhook")
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
# Временно отключаем проверку подписи для отладки
|
||||
# TODO: Включить обратно после настройки MulenPay
|
||||
if not self._verify_mulenpay_signature(request, raw_body):
|
||||
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
|
||||
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
# Получаем соединение с БД
|
||||
db_generator = get_db()
|
||||
db = await db_generator.__anext__()
|
||||
|
||||
try:
|
||||
success = await payment_service.process_mulenpay_callback(db, payload)
|
||||
if success:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
|
||||
finally:
|
||||
try:
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
|
||||
|
||||
@staticmethod
|
||||
def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]:
|
||||
for header_name in header_names:
|
||||
value = request.headers.get(header_name)
|
||||
if value:
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
|
||||
secret_key = settings.MULENPAY_SECRET_KEY
|
||||
if not secret_key:
|
||||
logger.error("Mulen Pay secret key is not configured")
|
||||
return False
|
||||
|
||||
# Логируем все заголовки для отладки
|
||||
logger.info("MulenPay webhook headers for signature verification:")
|
||||
for header_name, header_value in request.headers.items():
|
||||
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
|
||||
logger.info(f" {header_name}: {header_value}")
|
||||
|
||||
signature = WebhookServer._extract_mulenpay_header(
|
||||
request,
|
||||
(
|
||||
'X-MulenPay-Signature',
|
||||
'X-Mulenpay-Signature',
|
||||
'X-MULENPAY-SIGNATURE',
|
||||
'X-MulenPay-Webhook-Signature',
|
||||
'X-Mulenpay-Webhook-Signature',
|
||||
'X-MULENPAY-WEBHOOK-SIGNATURE',
|
||||
'X-Signature',
|
||||
'Signature',
|
||||
'X-MulenPay-Sign',
|
||||
'X-Mulenpay-Sign',
|
||||
'X-MULENPAY-SIGN',
|
||||
'MulenPay-Signature',
|
||||
'Mulenpay-Signature',
|
||||
'MULENPAY-SIGNATURE',
|
||||
'signature',
|
||||
'sign',
|
||||
)
|
||||
)
|
||||
if signature:
|
||||
normalized_signature = signature
|
||||
if normalized_signature.lower().startswith('sha256='):
|
||||
normalized_signature = normalized_signature.split('=', 1)[1].strip()
|
||||
|
||||
hmac_digest = hmac.new(
|
||||
secret_key.encode('utf-8'),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
expected_hex_signature = hmac_digest.hex()
|
||||
expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip()
|
||||
expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip()
|
||||
|
||||
normalized_signature_lower = normalized_signature.lower()
|
||||
if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()):
|
||||
return True
|
||||
|
||||
normalized_signature_no_padding = normalized_signature.rstrip('=')
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
logger.error("Неверная подпись Mulen Pay webhook")
|
||||
return False
|
||||
|
||||
authorization_header = request.headers.get('Authorization')
|
||||
if authorization_header:
|
||||
scheme, _, value = authorization_header.partition(' ')
|
||||
scheme_lower = scheme.lower()
|
||||
token = value.strip() if value else scheme.strip()
|
||||
|
||||
if scheme_lower in ('bearer', 'token'):
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
|
||||
return False
|
||||
|
||||
if not value and hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
fallback_token = WebhookServer._extract_mulenpay_header(
|
||||
request,
|
||||
(
|
||||
'X-MulenPay-Token',
|
||||
'X-Mulenpay-Token',
|
||||
'X-Webhook-Token',
|
||||
)
|
||||
)
|
||||
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
"Mulen Pay webhook headers received: %s",
|
||||
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
|
||||
)
|
||||
|
||||
logger.error("Отсутствует подпись Mulen Pay webhook")
|
||||
return False
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
try:
|
||||
logger.info(f"Получен Tribute webhook: {request.method} {request.path}")
|
||||
logger.info(f"Headers: {dict(request.headers)}")
|
||||
|
||||
Vendored
+13
-4
@@ -218,15 +218,24 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host='0.0.0.0',
|
||||
runner,
|
||||
host=settings.YOOKASSA_WEBHOOK_HOST,
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
await site.start()
|
||||
|
||||
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
logger.info(
|
||||
"✅ YooKassa webhook сервер запущен на %s:%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"🎯 YooKassa webhook URL: http://%s:%s%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -333,6 +333,35 @@ async def show_campaign_detail(
|
||||
f"• Выдано баланса: <b>{texts.format_price(stats['balance_issued'])}</b>"
|
||||
)
|
||||
text.append(f"• Выдано подписок: <b>{stats['subscription_issued']}</b>")
|
||||
text.append(
|
||||
f"• Доход: <b>{texts.format_price(stats['total_revenue_kopeks'])}</b>"
|
||||
)
|
||||
text.append(
|
||||
"• Получили триал: "
|
||||
f"<b>{stats['trial_users_count']}</b>"
|
||||
f" (активно: {stats['active_trials_count']})"
|
||||
)
|
||||
text.append(
|
||||
"• Конверсий в оплату: "
|
||||
f"<b>{stats['conversion_count']}</b>"
|
||||
f" / пользователей с оплатой: {stats['paid_users_count']}"
|
||||
)
|
||||
text.append(
|
||||
"• Конверсия в оплату: "
|
||||
f"<b>{stats['conversion_rate']:.1f}%</b>"
|
||||
)
|
||||
text.append(
|
||||
"• Конверсия триала: "
|
||||
f"<b>{stats['trial_conversion_rate']:.1f}%</b>"
|
||||
)
|
||||
text.append(
|
||||
"• Средний доход на пользователя: "
|
||||
f"<b>{texts.format_price(stats['avg_revenue_per_user_kopeks'])}</b>"
|
||||
)
|
||||
text.append(
|
||||
"• Средний первый платеж: "
|
||||
f"<b>{texts.format_price(stats['avg_first_payment_kopeks'])}</b>"
|
||||
)
|
||||
if stats["last_registration"]:
|
||||
text.append(
|
||||
f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+154
-15
@@ -10,13 +10,18 @@ from app.keyboards.admin import (
|
||||
get_admin_users_submenu_keyboard,
|
||||
get_admin_promo_submenu_keyboard,
|
||||
get_admin_communications_submenu_keyboard,
|
||||
get_admin_support_submenu_keyboard,
|
||||
get_admin_settings_submenu_keyboard,
|
||||
get_admin_system_submenu_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.handlers.admin import support_settings as support_settings_handlers
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.database.crud.rules import clear_all_rules, get_rules_statistics
|
||||
from app.localization.texts import clear_rules_cache
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,10 +71,10 @@ async def show_users_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"👥 **Управление пользователями и подписками**\n\n"
|
||||
"Выберите нужный раздел:",
|
||||
texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") +
|
||||
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
|
||||
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -84,10 +89,10 @@ async def show_promo_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"💰 **Промокоды и статистика**\n\n"
|
||||
"Выберите нужный раздел:",
|
||||
texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") +
|
||||
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
|
||||
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -102,16 +107,135 @@ async def show_communications_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 **Коммуникации**\n\n"
|
||||
"Управление рассылками и текстами интерфейса:",
|
||||
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") +
|
||||
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"),
|
||||
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
# Moderators have access only to tickets and not to settings
|
||||
is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
|
||||
kb = get_admin_support_submenu_keyboard(db_user.language)
|
||||
if is_moderator_only:
|
||||
# Rebuild keyboard to include only tickets and back to main menu
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + (
|
||||
texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.")
|
||||
if is_moderator_only
|
||||
else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:")
|
||||
),
|
||||
reply_markup=kb,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# Moderator panel entry (from main menu quick button)
|
||||
async def show_moderator_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑⚖️ <b>Модерация поддержки</b>") + "\n\n" +
|
||||
texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."),
|
||||
parse_mode="HTML",
|
||||
reply_markup=kb
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_audit(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
# pagination
|
||||
page = 1
|
||||
if callback.data.startswith("admin_support_audit_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
page = 1
|
||||
per_page = 10
|
||||
total = await TicketCRUD.count_support_audit(db)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
offset = (page - 1) * per_page
|
||||
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
|
||||
|
||||
lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 <b>Аудит модераторов</b>"), ""]
|
||||
if not logs:
|
||||
lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто"))
|
||||
else:
|
||||
for log in logs:
|
||||
role = (
|
||||
texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор")
|
||||
if getattr(log, 'is_moderator', False)
|
||||
else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ")
|
||||
)
|
||||
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
|
||||
action_map = {
|
||||
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
|
||||
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
|
||||
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
|
||||
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
|
||||
}
|
||||
action_text = action_map.get(log.action, log.action)
|
||||
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
|
||||
details = log.details or {}
|
||||
extra = ""
|
||||
if log.action == 'block_user_timed' and 'minutes' in details:
|
||||
extra = f" ({details['minutes']} мин)"
|
||||
lines.append(f"{ts} • {role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
|
||||
|
||||
# keyboard with pagination
|
||||
nav_row = []
|
||||
if total_pages > 1:
|
||||
if page > 1:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}"))
|
||||
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page"))
|
||||
if page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}"))
|
||||
|
||||
kb_rows = []
|
||||
if nav_row:
|
||||
kb_rows.append(nav_row)
|
||||
kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")])
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
|
||||
|
||||
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_settings_submenu(
|
||||
@@ -120,10 +244,10 @@ async def show_settings_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⚙️ **Настройки системы**\n\n"
|
||||
"Управление Remnawave, мониторингом и другими настройками:",
|
||||
texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") +
|
||||
texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"),
|
||||
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -138,10 +262,10 @@ async def show_system_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🛠️ **Системные функции**\n\n"
|
||||
"Обновления, резервные копии и системные операции:",
|
||||
texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") +
|
||||
texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"),
|
||||
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -284,6 +408,15 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "admin_submenu_communications"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_support_submenu,
|
||||
F.data == "admin_submenu_support"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_support_audit,
|
||||
F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_settings_submenu,
|
||||
F.data == "admin_submenu_settings"
|
||||
@@ -293,6 +426,12 @@ def register_handlers(dp: Dispatcher):
|
||||
show_system_submenu,
|
||||
F.data == "admin_submenu_system"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_moderator_panel,
|
||||
F.data == "moderator_panel"
|
||||
)
|
||||
# Support settings module
|
||||
support_settings_handlers.register_handlers(dp)
|
||||
|
||||
dp.message.register(
|
||||
clear_rules_command,
|
||||
|
||||
+113
-29
@@ -9,14 +9,20 @@ from sqlalchemy import select, func, and_, or_
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
BroadcastHistory,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
|
||||
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
|
||||
BROADCAST_BUTTONS, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
|
||||
BROADCAST_BUTTON_LABELS
|
||||
BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
|
||||
get_broadcast_button_config, get_broadcast_button_labels
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
@@ -25,10 +31,8 @@ from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BUTTON_CONFIG = BROADCAST_BUTTONS
|
||||
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
|
||||
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
|
||||
BUTTON_LABELS = BROADCAST_BUTTON_LABELS
|
||||
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
@@ -39,16 +43,17 @@ def get_updated_message_buttons_selector_keyboard(selected_buttons: list, langua
|
||||
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKeyboardMarkup]:
|
||||
def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]:
|
||||
selected_buttons = selected_buttons or []
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
button_config_map = get_broadcast_button_config(language)
|
||||
|
||||
for row in BUTTON_ROWS:
|
||||
row_buttons: list[types.InlineKeyboardButton] = []
|
||||
for button_key in row:
|
||||
if button_key not in selected_buttons:
|
||||
continue
|
||||
button_config = BUTTON_CONFIG[button_key]
|
||||
button_config = button_config_map[button_key]
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=button_config["text"],
|
||||
@@ -260,14 +265,21 @@ async def select_broadcast_target(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
target = callback.data.split('_')[-1]
|
||||
|
||||
raw_target = callback.data[len("broadcast_"):]
|
||||
target_aliases = {
|
||||
"no_sub": "no",
|
||||
}
|
||||
target = target_aliases.get(raw_target, raw_target)
|
||||
|
||||
target_names = {
|
||||
"all": "Всем пользователям",
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"expiring": "С истекающей подпиской"
|
||||
"expiring": "С истекающей подпиской",
|
||||
"expired": "С истекшей подпиской",
|
||||
"active_zero": "Активная подписка, трафик 0 ГБ",
|
||||
"trial_zero": "Триальная подписка, трафик 0 ГБ",
|
||||
}
|
||||
|
||||
user_count = await get_target_users_count(db, target)
|
||||
@@ -615,7 +627,8 @@ async def confirm_button_selection(
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
|
||||
|
||||
ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row]
|
||||
selected_names = [BUTTON_LABELS[key] for key in ordered_keys if key in selected_buttons]
|
||||
button_labels = get_broadcast_button_labels(db_user.language)
|
||||
selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons]
|
||||
if selected_names:
|
||||
buttons_info = f"\n📘 <b>Кнопки:</b> {', '.join(selected_names)}"
|
||||
else:
|
||||
@@ -732,7 +745,7 @@ async def confirm_broadcast(
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons)
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
@@ -817,22 +830,88 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
|
||||
async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
if target == "all":
|
||||
return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
elif target == "active":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial]
|
||||
elif target == "trial":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_trial]
|
||||
elif target == "no":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if not user.subscription or not user.subscription.is_active]
|
||||
elif target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
else:
|
||||
return []
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
|
||||
if target == "all":
|
||||
return users
|
||||
|
||||
if target == "active":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_active
|
||||
and not user.subscription.is_trial
|
||||
]
|
||||
|
||||
if target == "trial":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription and user.subscription.is_trial
|
||||
]
|
||||
|
||||
if target == "no":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if not user.subscription or not user.subscription.is_active
|
||||
]
|
||||
|
||||
if target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
|
||||
if target == "expired":
|
||||
now = datetime.utcnow()
|
||||
expired_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
}
|
||||
expired_users = []
|
||||
for user in users:
|
||||
subscription = user.subscription
|
||||
if subscription:
|
||||
if subscription.status in expired_statuses:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
if subscription.end_date <= now and not subscription.is_active:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
elif user.has_had_paid_subscription:
|
||||
expired_users.append(user)
|
||||
return expired_users
|
||||
|
||||
if target == "active_zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "trial_zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
@@ -956,7 +1035,12 @@ def get_target_name(target_type: str) -> str:
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"sub": "Без подписки",
|
||||
"expiring": "С истекающей подпиской",
|
||||
"expired": "С истекшей подпиской",
|
||||
"active_zero": "Активная подписка, трафик 0 ГБ",
|
||||
"trial_zero": "Триальная подписка, трафик 0 ГБ",
|
||||
"zero": "Подписка, трафик 0 ГБ",
|
||||
"custom_today": "Зарегистрированные сегодня",
|
||||
"custom_week": "Зарегистрированные за неделю",
|
||||
"custom_month": "Зарегистрированные за месяц",
|
||||
|
||||
@@ -2,8 +2,10 @@ import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
@@ -12,11 +14,366 @@ from app.utils.decorators import admin_required
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.states import AdminStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
|
||||
def _format_toggle(enabled: bool) -> str:
|
||||
return "🟢 Вкл" if enabled else "🔴 Выкл"
|
||||
|
||||
|
||||
def _build_notification_settings_view(language: str):
|
||||
texts = get_texts(language)
|
||||
config = NotificationSettingsService.get_config()
|
||||
|
||||
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
second_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
third_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
|
||||
trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
|
||||
trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
|
||||
trial_channel_status = _format_toggle(
|
||||
config["trial_channel_unsubscribed"].get("enabled", True)
|
||||
)
|
||||
expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
|
||||
second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
|
||||
third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
|
||||
|
||||
summary_text = (
|
||||
"🔔 <b>Уведомления пользователям</b>\n\n"
|
||||
f"• 1 час после триала: {trial_1h_status}\n"
|
||||
f"• 24 часа после триала: {trial_24h_status}\n"
|
||||
f"• Отписка от канала: {trial_channel_status}\n"
|
||||
f"• 1 день после истечения: {expired_1d_status}\n"
|
||||
f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
|
||||
f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")],
|
||||
[InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")],
|
||||
[InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")],
|
||||
[InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")],
|
||||
[InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: скидка 2-3 день", callback_data="admin_mon_notify_preview_expired_2d")],
|
||||
[InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")],
|
||||
[InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")],
|
||||
[InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: скидка спустя дни", callback_data="admin_mon_notify_preview_expired_nd")],
|
||||
[InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")],
|
||||
[InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")],
|
||||
[InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")],
|
||||
[InlineKeyboardButton(text="🧪 Отправить все тесты", callback_data="admin_mon_notify_preview_all")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")],
|
||||
])
|
||||
|
||||
return summary_text, keyboard
|
||||
|
||||
|
||||
def _build_notification_preview_message(language: str, notification_type: str):
|
||||
texts = get_texts(language)
|
||||
now = datetime.now()
|
||||
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
header = "🧪 <b>Тестовое уведомление мониторинга</b>\n\n"
|
||||
|
||||
if notification_type == "trial_inactive_1h":
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_1H",
|
||||
(
|
||||
"⏳ <b>Прошёл час, а подключения нет</b>\n\n"
|
||||
"Если возникли сложности с запуском — воспользуйтесь инструкциями."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
price=price_30_days,
|
||||
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "trial_inactive_24h":
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_24H",
|
||||
(
|
||||
"⏳ <b>Вы ещё не подключились к VPN</b>\n\n"
|
||||
"Прошли сутки с активации тестового периода, но трафик не зафиксирован."
|
||||
"\n\nНажмите кнопку ниже, чтобы подключиться."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
price=price_30_days,
|
||||
end_date=(now + timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "trial_channel_unsubscribed":
|
||||
template = texts.get(
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED",
|
||||
(
|
||||
"🚫 <b>Доступ приостановлен</b>\n\n"
|
||||
"Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
|
||||
"Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
|
||||
),
|
||||
)
|
||||
check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
|
||||
message = template.format(check_button=check_button)
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
if settings.CHANNEL_LINK:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
||||
url=settings.CHANNEL_LINK,
|
||||
)
|
||||
]
|
||||
)
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=check_button,
|
||||
callback_data="sub_channel_check",
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
elif notification_type == "expired_1d":
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_1D",
|
||||
(
|
||||
"⛔ <b>Подписка закончилась</b>\n\n"
|
||||
"Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
end_date=(now - timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
|
||||
price=price_30_days,
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "expired_2d":
|
||||
percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
|
||||
(
|
||||
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
|
||||
"Активируйте предложение, чтобы получить дополнительную скидку. "
|
||||
"Она суммируется с вашей промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
|
||||
trigger_days=3,
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Получить скидку",
|
||||
callback_data="claim_discount_preview",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "expired_nd":
|
||||
percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
|
||||
(
|
||||
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
|
||||
"Она суммируется с промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
trigger_days=trigger_days,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Получить скидку",
|
||||
callback_data="claim_discount_preview",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported notification type: {notification_type}")
|
||||
|
||||
footer = "\n\n<i>Сообщение отправлено только вам для проверки оформления.</i>"
|
||||
return header + message + footer, keyboard
|
||||
|
||||
|
||||
async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None:
|
||||
message, keyboard = _build_notification_preview_message(language, notification_type)
|
||||
await bot.send_message(
|
||||
chat_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
async def _render_notification_settings(callback: CallbackQuery) -> None:
|
||||
language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
text, keyboard = _build_notification_settings_view(language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
|
||||
async def _render_notification_settings_for_state(
|
||||
bot,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
language: str,
|
||||
business_connection_id: str | None = None,
|
||||
) -> None:
|
||||
text, keyboard = _build_notification_settings_view(language)
|
||||
|
||||
edit_kwargs = {
|
||||
"text": text,
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": keyboard,
|
||||
}
|
||||
|
||||
if business_connection_id:
|
||||
edit_kwargs["business_connection_id"] = business_connection_id
|
||||
|
||||
try:
|
||||
await bot.edit_message_text(**edit_kwargs)
|
||||
except TelegramBadRequest as exc:
|
||||
if "no text in the message to edit" in (exc.message or "").lower():
|
||||
caption_kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"caption": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": keyboard,
|
||||
}
|
||||
|
||||
if business_connection_id:
|
||||
caption_kwargs["business_connection_id"] = business_connection_id
|
||||
|
||||
await bot.edit_message_caption(**caption_kwargs)
|
||||
else:
|
||||
raise
|
||||
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
@@ -43,7 +400,8 @@ async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
🔧 Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = get_monitoring_keyboard()
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
keyboard = get_monitoring_keyboard(language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
@@ -52,6 +410,287 @@ async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
await callback.answer("❌ Ошибка получения данных", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_settings")
|
||||
@admin_required
|
||||
async def admin_monitoring_settings(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены"
|
||||
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
third_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
|
||||
text = (
|
||||
"⚙️ <b>Настройки мониторинга</b>\n\n"
|
||||
f"🔔 <b>Уведомления пользователям:</b> {global_status}\n"
|
||||
f"• Скидка 2-3 дня: {second_percent}%\n"
|
||||
f"• Скидка после {third_days} дней: {third_percent}%\n\n"
|
||||
"Выберите раздел для настройки."
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")],
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отображения настроек мониторинга: {e}")
|
||||
await callback.answer("❌ Не удалось открыть настройки", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_settings")
|
||||
@admin_required
|
||||
async def admin_notify_settings(callback: CallbackQuery):
|
||||
try:
|
||||
await _render_notification_settings(callback)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отображения настроек уведомлений: {e}")
|
||||
await callback.answer("❌ Не удалось загрузить настройки", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h")
|
||||
@admin_required
|
||||
async def toggle_trial_1h_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
|
||||
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_1h")
|
||||
@admin_required
|
||||
async def preview_trial_1h_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_1h")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial 1h preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h")
|
||||
@admin_required
|
||||
async def toggle_trial_24h_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
|
||||
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_24h")
|
||||
@admin_required
|
||||
async def preview_trial_24h_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_24h")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial 24h preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel")
|
||||
@admin_required
|
||||
async def toggle_trial_channel_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
|
||||
NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel")
|
||||
@admin_required
|
||||
async def preview_trial_channel_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial channel preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
|
||||
@admin_required
|
||||
async def toggle_expired_1d_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_expired_1d_enabled()
|
||||
NotificationSettingsService.set_expired_1d_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_1d")
|
||||
@admin_required
|
||||
async def preview_expired_1d_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_1d")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send expired 1d preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d")
|
||||
@admin_required
|
||||
async def toggle_second_wave_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_second_wave_enabled()
|
||||
NotificationSettingsService.set_second_wave_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_2d")
|
||||
@admin_required
|
||||
async def preview_second_wave_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_2d")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send second wave preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd")
|
||||
@admin_required
|
||||
async def toggle_third_wave_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_third_wave_enabled()
|
||||
NotificationSettingsService.set_third_wave_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_nd")
|
||||
@admin_required
|
||||
async def preview_third_wave_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_nd")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send third wave preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_all")
|
||||
@admin_required
|
||||
async def preview_all_notifications(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
chat_id = callback.from_user.id
|
||||
for notification_type in [
|
||||
"trial_inactive_1h",
|
||||
"trial_inactive_24h",
|
||||
"trial_channel_unsubscribed",
|
||||
"expired_1d",
|
||||
"expired_2d",
|
||||
"expired_nd",
|
||||
]:
|
||||
await _send_notification_preview(callback.bot, chat_id, language, notification_type)
|
||||
await callback.answer("✅ Все тестовые уведомления отправлены")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send all notification previews: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тесты", show_alert=True)
|
||||
|
||||
|
||||
async def _start_notification_value_edit(
|
||||
callback: CallbackQuery,
|
||||
state: FSMContext,
|
||||
setting_key: str,
|
||||
field: str,
|
||||
prompt_key: str,
|
||||
default_prompt: str,
|
||||
):
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await state.set_state(AdminStates.editing_notification_value)
|
||||
await state.update_data(
|
||||
notification_setting_key=setting_key,
|
||||
notification_setting_field=field,
|
||||
settings_message_chat=callback.message.chat.id,
|
||||
settings_message_id=callback.message.message_id,
|
||||
settings_business_connection_id=(
|
||||
str(getattr(callback.message, "business_connection_id", None))
|
||||
if getattr(callback.message, "business_connection_id", None) is not None
|
||||
else None
|
||||
),
|
||||
settings_language=language,
|
||||
)
|
||||
texts = get_texts(language)
|
||||
await callback.answer()
|
||||
await callback.message.answer(texts.get(prompt_key, default_prompt))
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent")
|
||||
@admin_required
|
||||
async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_second_wave",
|
||||
"percent",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT",
|
||||
"Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours")
|
||||
@admin_required
|
||||
async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_second_wave",
|
||||
"hours",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS",
|
||||
"Введите количество часов действия скидки (1-168):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent")
|
||||
@admin_required
|
||||
async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"percent",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT",
|
||||
"Введите новый процент скидки для позднего предложения (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours")
|
||||
@admin_required
|
||||
async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"hours",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS",
|
||||
"Введите количество часов действия скидки (1-168):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold")
|
||||
@admin_required
|
||||
async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"trigger",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS",
|
||||
"Через сколько дней после истечения отправлять предложение? (минимум 2):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_start")
|
||||
@admin_required
|
||||
async def start_monitoring_callback(callback: CallbackQuery):
|
||||
@@ -366,5 +1005,74 @@ async def monitoring_command(message: Message):
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(AdminStates.editing_notification_value)
|
||||
async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
if not data:
|
||||
await state.clear()
|
||||
await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.")
|
||||
return
|
||||
|
||||
raw_value = (message.text or "").strip()
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число."))
|
||||
return
|
||||
|
||||
key = data.get("notification_setting_key")
|
||||
field = data.get("notification_setting_field")
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
success = False
|
||||
if key == "expired_second_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_second_wave_discount_percent(value)
|
||||
elif key == "expired_second_wave" and field == "hours":
|
||||
success = NotificationSettingsService.set_second_wave_valid_hours(value)
|
||||
elif key == "expired_third_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_third_wave_discount_percent(value)
|
||||
elif key == "expired_third_wave" and field == "hours":
|
||||
success = NotificationSettingsService.set_third_wave_valid_hours(value)
|
||||
elif key == "expired_third_wave" and field == "trigger":
|
||||
success = NotificationSettingsService.set_third_wave_trigger_days(value)
|
||||
|
||||
if not success:
|
||||
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова."))
|
||||
return
|
||||
|
||||
back_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.get("BACK", "⬅️ Назад"),
|
||||
callback_data="admin_mon_notify_settings",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены."),
|
||||
reply_markup=back_keyboard,
|
||||
)
|
||||
|
||||
chat_id = data.get("settings_message_chat")
|
||||
message_id = data.get("settings_message_id")
|
||||
business_connection_id = data.get("settings_business_connection_id")
|
||||
if chat_id and message_id:
|
||||
await _render_notification_settings_for_state(
|
||||
message.bot,
|
||||
chat_id,
|
||||
message_id,
|
||||
language,
|
||||
business_connection_id=business_connection_id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
dp.include_router(router)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,512 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_timestamp(value: datetime | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _build_overview(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
|
||||
has_content = bool(policy and policy.content and policy.content.strip())
|
||||
|
||||
description = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION",
|
||||
"Политика конфиденциальности отображается в разделе «Инфо».",
|
||||
)
|
||||
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED",
|
||||
"⚠️ Показ политики выключен или текст отсутствует.",
|
||||
)
|
||||
if policy and policy.is_enabled and has_content:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED",
|
||||
"✅ Политика активна и показывается пользователям.",
|
||||
)
|
||||
elif policy and policy.is_enabled:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY",
|
||||
"⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
|
||||
)
|
||||
|
||||
updated_at = _format_timestamp(getattr(policy, "updated_at", None))
|
||||
updated_block = ""
|
||||
if updated_at:
|
||||
updated_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT",
|
||||
"Последнее обновление: {timestamp}",
|
||||
).format(timestamp=updated_at)
|
||||
|
||||
preview_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY",
|
||||
"Текст ещё не задан.",
|
||||
)
|
||||
if has_content:
|
||||
preview_title = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE",
|
||||
"<b>Превью текста:</b>",
|
||||
)
|
||||
preview_raw = policy.content.strip()
|
||||
preview_trimmed = preview_raw[:400]
|
||||
if len(preview_raw) > 400:
|
||||
preview_trimmed += "..."
|
||||
preview_block = (
|
||||
f"{preview_title}\n"
|
||||
f"<code>{html.escape(preview_trimmed)}</code>"
|
||||
)
|
||||
|
||||
language_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE",
|
||||
"Язык: <code>{lang}</code>",
|
||||
).format(lang=normalized_language)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HEADER",
|
||||
"🛡️ <b>Политика конфиденциальности</b>",
|
||||
)
|
||||
actions_prompt = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT",
|
||||
"Выберите действие:",
|
||||
)
|
||||
|
||||
message_parts = [
|
||||
header,
|
||||
description,
|
||||
language_block,
|
||||
status_text,
|
||||
]
|
||||
|
||||
if updated_block:
|
||||
message_parts.append(updated_block)
|
||||
|
||||
message_parts.append(preview_block)
|
||||
message_parts.append(actions_prompt)
|
||||
|
||||
overview_text = "\n\n".join(part for part in message_parts if part)
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
])
|
||||
|
||||
if has_content:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON",
|
||||
"👀 Просмотреть текущий текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_view",
|
||||
)
|
||||
])
|
||||
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON",
|
||||
"✅ Включить показ",
|
||||
)
|
||||
if policy and policy.is_enabled:
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON",
|
||||
"🚫 Отключить показ",
|
||||
)
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data="admin_privacy_policy_toggle",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_privacy_policy_help",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_submenu_settings",
|
||||
)
|
||||
])
|
||||
|
||||
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_privacy_policy_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
|
||||
logger.info(
|
||||
"Админ %s переключил показ политики конфиденциальности: %s",
|
||||
db_user.telegram_id,
|
||||
"enabled" if updated_policy.is_enabled else "disabled",
|
||||
)
|
||||
status_message = (
|
||||
texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена")
|
||||
if updated_policy.is_enabled
|
||||
else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена")
|
||||
)
|
||||
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(status_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
current_preview = ""
|
||||
if policy and policy.content:
|
||||
preview = policy.content.strip()[:400]
|
||||
if len(policy.content.strip()) > 400:
|
||||
preview += "..."
|
||||
current_preview = (
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW",
|
||||
"Текущий текст (превью):",
|
||||
)
|
||||
+ f"\n<code>{html.escape(preview)}</code>\n\n"
|
||||
)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT",
|
||||
"Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
|
||||
)
|
||||
|
||||
hint = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT",
|
||||
"Используйте /html_help для справки по тегам.",
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f"📝 <b>{texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}</b>\n\n"
|
||||
f"{current_preview}{prompt}\n\n{hint}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_privacy_policy_help",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"),
|
||||
callback_data="admin_privacy_policy_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.editing_privacy_policy)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_edit_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await state.clear()
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_privacy_policy_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.text or ""
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG",
|
||||
"❌ Текст политики слишком длинный. Максимум 4000 символов.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
is_valid, error_message = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR",
|
||||
"❌ Ошибка в HTML: {error}",
|
||||
).format(error=error_message)
|
||||
)
|
||||
return
|
||||
|
||||
await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
|
||||
logger.info(
|
||||
"Админ %s обновил текст политики конфиденциальности (%d символов)",
|
||||
db_user.telegram_id,
|
||||
len(new_text),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_SAVED",
|
||||
"✅ Политика конфиденциальности обновлена.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(success_text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
if not policy or not policy.content or not policy.content.strip():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT",
|
||||
"Текст политики пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content = policy.content.strip()
|
||||
truncated = False
|
||||
max_length = 3800
|
||||
if len(content) > max_length:
|
||||
content = content[: max_length - 3] + "..."
|
||||
truncated = True
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE",
|
||||
"👀 <b>Текущий текст политики</b>",
|
||||
)
|
||||
|
||||
note = ""
|
||||
if truncated:
|
||||
note = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED",
|
||||
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{header}\n\n{content}{note}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_privacy_policy_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
help_text = get_html_help_text()
|
||||
|
||||
current_state = await state.get_state()
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if current_state == AdminStates.editing_privacy_policy.state:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT",
|
||||
"⬅️ Назад к редактированию",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy_management,
|
||||
F.data == "admin_privacy_policy",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_privacy_policy,
|
||||
F.data == "admin_privacy_policy_toggle",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_edit_privacy_policy,
|
||||
F.data == "admin_privacy_policy_edit",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
cancel_edit_privacy_policy,
|
||||
F.data == "admin_privacy_policy_cancel",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
view_privacy_policy,
|
||||
F.data == "admin_privacy_policy_view",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy_html_help,
|
||||
F.data == "admin_privacy_policy_help",
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_privacy_policy_edit,
|
||||
AdminStates.editing_privacy_policy,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,530 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_timestamp(value: datetime | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _build_overview(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
normalized_language = PublicOfferService.normalize_language(db_user.language)
|
||||
has_content = bool(offer and offer.content and offer.content.strip())
|
||||
|
||||
description = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION",
|
||||
"Публичная оферта отображается в разделе «Инфо».",
|
||||
)
|
||||
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED",
|
||||
"⚠️ Показ оферты выключен или текст отсутствует.",
|
||||
)
|
||||
if offer and offer.is_enabled and has_content:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED",
|
||||
"✅ Оферта активна и показывается пользователям.",
|
||||
)
|
||||
elif offer and offer.is_enabled:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY",
|
||||
"⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
|
||||
)
|
||||
|
||||
updated_at = _format_timestamp(getattr(offer, "updated_at", None))
|
||||
updated_block = ""
|
||||
if updated_at:
|
||||
updated_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT",
|
||||
"Последнее обновление: {timestamp}",
|
||||
).format(timestamp=updated_at)
|
||||
|
||||
preview_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY",
|
||||
"Текст ещё не задан.",
|
||||
)
|
||||
if has_content:
|
||||
preview_title = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE",
|
||||
"<b>Превью текста:</b>",
|
||||
)
|
||||
preview_raw = offer.content.strip()
|
||||
preview_trimmed = preview_raw[:400]
|
||||
if len(preview_raw) > 400:
|
||||
preview_trimmed += "..."
|
||||
preview_block = (
|
||||
f"{preview_title}\n"
|
||||
f"<code>{html.escape(preview_trimmed)}</code>"
|
||||
)
|
||||
|
||||
language_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE",
|
||||
"Язык: <code>{lang}</code>",
|
||||
).format(lang=normalized_language)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HEADER",
|
||||
"📄 <b>Публичная оферта</b>",
|
||||
)
|
||||
actions_prompt = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT",
|
||||
"Выберите действие:",
|
||||
)
|
||||
|
||||
message_parts = [
|
||||
header,
|
||||
description,
|
||||
language_block,
|
||||
status_text,
|
||||
]
|
||||
|
||||
if updated_block:
|
||||
message_parts.append(updated_block)
|
||||
|
||||
message_parts.append(preview_block)
|
||||
message_parts.append(actions_prompt)
|
||||
|
||||
overview_text = "\n\n".join(part for part in message_parts if part)
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
])
|
||||
|
||||
if has_content:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON",
|
||||
"👀 Просмотреть текущий текст",
|
||||
),
|
||||
callback_data="admin_public_offer_view",
|
||||
)
|
||||
])
|
||||
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON",
|
||||
"✅ Включить показ",
|
||||
)
|
||||
if offer and offer.is_enabled:
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON",
|
||||
"🚫 Отключить показ",
|
||||
)
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data="admin_public_offer_toggle",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_public_offer_help",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_submenu_settings",
|
||||
)
|
||||
])
|
||||
|
||||
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_public_offer_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language)
|
||||
logger.info(
|
||||
"Админ %s переключил показ публичной оферты: %s",
|
||||
db_user.telegram_id,
|
||||
"enabled" if updated_offer.is_enabled else "disabled",
|
||||
)
|
||||
status_message = (
|
||||
texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена")
|
||||
if updated_offer.is_enabled
|
||||
else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена")
|
||||
)
|
||||
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(status_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
current_preview = ""
|
||||
if offer and offer.content:
|
||||
preview = offer.content.strip()[:400]
|
||||
if len(offer.content.strip()) > 400:
|
||||
preview += "..."
|
||||
current_preview = (
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW",
|
||||
"Текущий текст (превью):",
|
||||
)
|
||||
+ f"\n<code>{html.escape(preview)}</code>\n\n"
|
||||
)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT",
|
||||
"Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
|
||||
)
|
||||
|
||||
hint = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT",
|
||||
"Используйте /html_help для справки по тегам.",
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f"📝 <b>{texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}</b>\n\n"
|
||||
f"{current_preview}{prompt}\n\n{hint}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_public_offer_help",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"),
|
||||
callback_data="admin_public_offer_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.editing_public_offer)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_edit_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await state.clear()
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED",
|
||||
"Редактирование оферты отменено.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_public_offer_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.text or ""
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG",
|
||||
"❌ Текст оферты слишком длинный. Максимум 4000 символов.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
is_valid, error_message = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR",
|
||||
"❌ Ошибка в HTML: {error}",
|
||||
).format(error=error_message)
|
||||
)
|
||||
return
|
||||
|
||||
await PublicOfferService.save_offer(db, db_user.language, new_text)
|
||||
logger.info(
|
||||
"Админ %s обновил текст публичной оферты (%d символов)",
|
||||
db_user.telegram_id,
|
||||
len(new_text),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_SAVED",
|
||||
"✅ Публичная оферта обновлена.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(success_text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
if not offer or not offer.content or not offer.content.strip():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
|
||||
"Текст оферты пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content = offer.content.strip()
|
||||
max_length = 3800
|
||||
pages = PublicOfferService.split_content_into_pages(
|
||||
content,
|
||||
max_length=max_length,
|
||||
)
|
||||
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
|
||||
"Текст оферты пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
preview = pages[0]
|
||||
truncated = len(pages) > 1
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE",
|
||||
"👀 <b>Текущий текст оферты</b>",
|
||||
)
|
||||
|
||||
note = ""
|
||||
if truncated:
|
||||
note = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED",
|
||||
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{header}\n\n{preview}{note}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_public_offer_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
help_text = get_html_help_text()
|
||||
|
||||
current_state = await state.get_state()
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if current_state == AdminStates.editing_public_offer.state:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT",
|
||||
"⬅️ Назад к редактированию",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_public_offer_management,
|
||||
F.data == "admin_public_offer",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_public_offer,
|
||||
F.data == "admin_public_offer_toggle",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_edit_public_offer,
|
||||
F.data == "admin_public_offer_edit",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
cancel_edit_public_offer,
|
||||
F.data == "admin_public_offer_cancel",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
view_public_offer,
|
||||
F.data == "admin_public_offer_view",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_public_offer_html_help,
|
||||
F.data == "admin_public_offer_help",
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_public_offer_edit,
|
||||
AdminStates.editing_public_offer,
|
||||
)
|
||||
@@ -68,7 +68,7 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
|
||||
@@ -105,7 +105,7 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<i>🕐 Время: {current_time}</i>
|
||||
@@ -190,7 +190,7 @@ async def show_referral_settings(
|
||||
• Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
• Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
• Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
|
||||
<b>Комиссионные:</b>
|
||||
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
@@ -30,14 +30,22 @@ async def show_remnawave_menu(
|
||||
):
|
||||
remnawave_service = RemnaWaveService()
|
||||
connection_test = await remnawave_service.test_api_connection()
|
||||
|
||||
status_emoji = "✅" if connection_test["status"] == "connected" else "❌"
|
||||
|
||||
|
||||
status = connection_test.get("status")
|
||||
if status == "connected":
|
||||
status_emoji = "✅"
|
||||
elif status == "not_configured":
|
||||
status_emoji = "ℹ️"
|
||||
else:
|
||||
status_emoji = "❌"
|
||||
|
||||
api_url_display = settings.REMNAWAVE_API_URL or "—"
|
||||
|
||||
text = f"""
|
||||
🖥️ <b>Управление Remnawave</b>
|
||||
|
||||
📡 <b>Соединение:</b> {status_emoji} {connection_test["message"]}
|
||||
🌐 <b>URL:</b> <code>{settings.REMNAWAVE_API_URL}</code>
|
||||
📡 <b>Соединение:</b> {status_emoji} {connection_test.get("message", "Нет данных")}
|
||||
🌐 <b>URL:</b> <code>{api_url_display}</code>
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
@@ -194,7 +202,7 @@ async def show_traffic_stats(
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
try:
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
bandwidth_stats = await api.get_bandwidth_stats()
|
||||
|
||||
realtime_usage = await api.get_nodes_realtime_usage()
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import (
|
||||
get_admin_report_result_keyboard,
|
||||
get_admin_reports_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.reporting_service import (
|
||||
ReportPeriod,
|
||||
ReportingServiceError,
|
||||
reporting_service,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_reports_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await callback.message.edit_text(
|
||||
"📊 <b>Отчеты</b>\n\n"
|
||||
"Выберите период, чтобы отправить отчет в админский топик.",
|
||||
reply_markup=get_admin_reports_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_daily_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.DAILY, db_user.language)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_weekly_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.WEEKLY, db_user.language)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_monthly_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.MONTHLY, db_user.language)
|
||||
|
||||
|
||||
async def _send_report(
|
||||
callback: types.CallbackQuery,
|
||||
period: ReportPeriod,
|
||||
language: str,
|
||||
) -> None:
|
||||
try:
|
||||
report_text = await reporting_service.send_report(period, send_to_topic=True)
|
||||
except ReportingServiceError as exc:
|
||||
logger.warning("Не удалось отправить отчет: %s", exc)
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Непредвиденная ошибка при отправке отчета: %s", exc)
|
||||
await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.answer(
|
||||
report_text,
|
||||
reply_markup=get_admin_report_result_keyboard(language),
|
||||
)
|
||||
await callback.answer("Отчет отправлен в топик")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def close_report_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||
logger.warning("Не удалось закрыть сообщение отчета: %s", exc)
|
||||
await callback.answer(texts.t("REPORT_CLOSE_ERROR", "Не удалось закрыть отчет."), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer(texts.t("REPORT_CLOSED", "Отчет закрыт."))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(show_reports_menu, F.data == "admin_reports")
|
||||
dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily")
|
||||
dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly")
|
||||
dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly")
|
||||
dp.callback_query.register(close_report_message, F.data == "admin_close_report")
|
||||
|
||||
+480
-115
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -6,10 +7,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads, get_server_squad_by_id, update_server_squad,
|
||||
delete_server_squad, sync_with_remnawave, get_server_statistics,
|
||||
create_server_squad, get_available_server_squads
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_id,
|
||||
update_server_squad,
|
||||
delete_server_squad,
|
||||
sync_with_remnawave,
|
||||
get_server_statistics,
|
||||
create_server_squad,
|
||||
get_available_server_squads,
|
||||
update_server_squad_promo_groups,
|
||||
get_server_connected_users,
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.cache import cache
|
||||
@@ -17,6 +26,125 @@ from app.utils.cache import cache
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_server_edit_view(server):
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
promo_groups_text = (
|
||||
", ".join(sorted(pg.name for pg in server.allowed_promo_groups))
|
||||
if server.allowed_promo_groups
|
||||
else "Не выбраны"
|
||||
)
|
||||
|
||||
trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
• Промогруппы: {promo_groups_text}
|
||||
• Выдача триала: {trial_status}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎁 Выдавать сквад" if not server.is_trial_eligible else "🚫 Не выдавать сквад",
|
||||
callback_data=f"admin_server_trial_{server.id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list"),
|
||||
],
|
||||
]
|
||||
|
||||
return text, types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _build_server_promo_groups_keyboard(server_id: int, promo_groups, selected_ids):
|
||||
keyboard = []
|
||||
for group in promo_groups:
|
||||
emoji = "✅" if group["id"] in selected_ids else "⚪"
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{emoji} {group['name']}",
|
||||
callback_data=f"admin_server_promo_toggle_{server_id}_{group['id']}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💾 Сохранить", callback_data=f"admin_server_promo_save_{server_id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=f"admin_server_edit_{server_id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_servers_menu(
|
||||
@@ -52,7 +180,7 @@ async def show_servers_menu(
|
||||
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
]
|
||||
|
||||
@@ -164,9 +292,9 @@ async def sync_servers_with_remnawave(
|
||||
)
|
||||
return
|
||||
|
||||
created, updated, disabled = await sync_with_remnawave(db, squads)
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
text = f"""
|
||||
✅ <b>Синхронизация завершена</b>
|
||||
@@ -174,7 +302,7 @@ async def sync_servers_with_remnawave(
|
||||
📊 <b>Результаты:</b>
|
||||
• Создано новых серверов: {created}
|
||||
• Обновлено существующих: {updated}
|
||||
• Отключено неактивных: {disabled}
|
||||
• Удалено отсутствующих: {removed}
|
||||
• Всего обработано: {len(squads)}
|
||||
|
||||
ℹ️ Новые серверы созданы как недоступные.
|
||||
@@ -202,7 +330,7 @@ async def sync_servers_with_remnawave(
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -216,65 +344,161 @@ async def show_server_edit_menu(
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_users(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
payload = callback.data.split("admin_server_users_", 1)[-1]
|
||||
payload_parts = payload.split("_")
|
||||
|
||||
server_id = int(payload_parts[0])
|
||||
page = int(payload_parts[1]) if len(payload_parts) > 1 else 1
|
||||
page = max(page, 1)
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
users = await get_server_connected_users(db, server_id)
|
||||
total_users = len(users)
|
||||
|
||||
page_size = 10
|
||||
total_pages = max((total_users + page_size - 1) // page_size, 1)
|
||||
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
start_index = (page - 1) * page_size
|
||||
end_index = start_index + page_size
|
||||
page_users = users[start_index:end_index]
|
||||
|
||||
safe_name = html.escape(server.display_name or "—")
|
||||
safe_uuid = html.escape(server.squad_uuid or "—")
|
||||
|
||||
header = [
|
||||
"🌐 <b>Пользователи сервера</b>",
|
||||
"",
|
||||
f"• Сервер: {safe_name}",
|
||||
f"• UUID: <code>{safe_uuid}</code>",
|
||||
f"• Подключений: {total_users}",
|
||||
]
|
||||
|
||||
|
||||
if total_pages > 1:
|
||||
header.append(f"• Страница: {page}/{total_pages}")
|
||||
|
||||
header.append("")
|
||||
|
||||
text = "\n".join(header)
|
||||
|
||||
def _get_status_icon(status_text: str) -> str:
|
||||
if not status_text:
|
||||
return ""
|
||||
|
||||
parts = status_text.split(" ", 1)
|
||||
return parts[0] if parts else status_text
|
||||
|
||||
if users:
|
||||
lines = []
|
||||
for index, user in enumerate(page_users, start=start_index + 1):
|
||||
safe_user_name = html.escape(user.full_name)
|
||||
lines.append(f"{index}. {safe_user_name}")
|
||||
|
||||
text += "\n" + "\n".join(lines)
|
||||
else:
|
||||
text += "Пользователи не найдены."
|
||||
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
for user in page_users:
|
||||
display_name = user.full_name
|
||||
if len(display_name) > 30:
|
||||
display_name = display_name[:27] + "..."
|
||||
|
||||
subscription_status = (
|
||||
user.subscription.status_display
|
||||
if user.subscription
|
||||
else "❌ Нет подписки"
|
||||
)
|
||||
status_icon = _get_status_icon(subscription_status)
|
||||
|
||||
if status_icon:
|
||||
button_text = f"{status_icon} {display_name}"
|
||||
else:
|
||||
button_text = display_name
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}",
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_buttons: list[types.InlineKeyboardButton] = []
|
||||
|
||||
if page > 1:
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Предыдущая",
|
||||
callback_data=f"admin_server_users_{server_id}_{page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"Стр. {page}/{total_pages}",
|
||||
callback_data=f"admin_server_users_{server_id}_{page}",
|
||||
)
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="Следующая ➡️",
|
||||
callback_data=f"admin_server_users_{server_id}_{page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(navigation_buttons)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ К серверу", callback_data=f"admin_server_edit_{server_id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ К списку", callback_data="admin_servers_list"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -296,65 +520,50 @@ async def toggle_server_availability(
|
||||
new_status = not server.is_available
|
||||
await update_server_squad(db, server_id, is_available=new_status)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
status_text = "включен" if new_status else "отключен"
|
||||
await callback.answer(f"✅ Сервер {status_text}!")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_trial_assignment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not server.is_trial_eligible
|
||||
await update_server_squad(db, server_id, is_trial_eligible=new_status)
|
||||
|
||||
status_text = "будет выдаваться" if new_status else "перестанет выдаваться"
|
||||
await callback.answer(f"✅ Сквад {status_text} в триал")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -422,7 +631,7 @@ async def process_server_price_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно"
|
||||
await message.answer(
|
||||
@@ -497,7 +706,7 @@ async def process_server_name_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Название сервера изменено на: <b>{new_name}</b>",
|
||||
@@ -570,7 +779,7 @@ async def delete_server_execute(
|
||||
success = await delete_server_squad(db, server_id)
|
||||
|
||||
if success:
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Сервер <b>{server.display_name}</b> успешно удален!",
|
||||
@@ -701,7 +910,7 @@ async def process_server_country_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
country_text = new_country or "Удален"
|
||||
await message.answer(
|
||||
@@ -847,11 +1056,12 @@ async def process_server_description_edit(
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, description=new_description)
|
||||
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
|
||||
desc_text = new_description or "Удалено"
|
||||
await cache.delete_pattern("available_countries*")
|
||||
await message.answer(
|
||||
f"✅ Описание сервера изменено:\n\n<i>{desc_text}</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -862,6 +1072,147 @@ async def process_server_description_edit(
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_promo_groups(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
promo_groups_data = await get_promo_groups_with_counts(db)
|
||||
promo_groups = [
|
||||
{"id": group.id, "name": group.name, "is_default": group.is_default}
|
||||
for group, _ in promo_groups_data
|
||||
]
|
||||
|
||||
if not promo_groups:
|
||||
await callback.answer("❌ Не найдены промогруппы", show_alert=True)
|
||||
return
|
||||
|
||||
selected_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
if not selected_ids:
|
||||
default_group = next((pg for pg in promo_groups if pg["is_default"]), None)
|
||||
if default_group:
|
||||
selected_ids.add(default_group["id"])
|
||||
|
||||
await state.set_state(AdminStates.editing_server_promo_groups)
|
||||
await state.set_data(
|
||||
{
|
||||
"server_id": server_id,
|
||||
"promo_groups": promo_groups,
|
||||
"selected_promo_groups": list(selected_ids),
|
||||
"server_name": server.display_name,
|
||||
}
|
||||
)
|
||||
|
||||
text = (
|
||||
"🎯 <b>Настройка промогрупп</b>\n\n"
|
||||
f"Сервер: <b>{server.display_name}</b>\n\n"
|
||||
"Выберите промогруппы, которым будет доступен этот сервер.\n"
|
||||
"Должна быть выбрана минимум одна промогруппа."
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
parts = callback.data.split('_')
|
||||
server_id = int(parts[4])
|
||||
group_id = int(parts[5])
|
||||
|
||||
data = await state.get_data()
|
||||
if not data or data.get("server_id") != server_id:
|
||||
await callback.answer("⚠️ Сессия редактирования устарела", show_alert=True)
|
||||
return
|
||||
|
||||
selected = set(int(pg_id) for pg_id in data.get("selected_promo_groups", []))
|
||||
promo_groups = data.get("promo_groups", [])
|
||||
|
||||
if group_id in selected:
|
||||
if len(selected) == 1:
|
||||
await callback.answer("⚠️ Нельзя отключить последнюю промогруппу", show_alert=True)
|
||||
return
|
||||
selected.remove(group_id)
|
||||
message = "Промогруппа отключена"
|
||||
else:
|
||||
selected.add(group_id)
|
||||
message = "Промогруппа добавлена"
|
||||
|
||||
await state.update_data(selected_promo_groups=list(selected))
|
||||
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected)
|
||||
)
|
||||
await callback.answer(message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def save_server_promo_groups(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
if not data:
|
||||
await callback.answer("⚠️ Нет данных для сохранения", show_alert=True)
|
||||
return
|
||||
|
||||
server_id = data.get("server_id")
|
||||
selected = data.get("selected_promo_groups", [])
|
||||
|
||||
if not selected:
|
||||
await callback.answer("❌ Выберите хотя бы одну промогруппу", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
server = await update_server_squad_promo_groups(db, server_id, selected)
|
||||
except ValueError as exc:
|
||||
await callback.answer(f"❌ {exc}", show_alert=True)
|
||||
return
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await cache.delete_pattern("available_countries*")
|
||||
await state.clear()
|
||||
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer("✅ Промогруппы обновлены!")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_server_user_counts_handler(
|
||||
@@ -934,12 +1285,24 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts")
|
||||
dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats")
|
||||
|
||||
dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc"))
|
||||
dp.callback_query.register(
|
||||
show_server_edit_menu,
|
||||
F.data.startswith("admin_server_edit_")
|
||||
& ~F.data.contains("name")
|
||||
& ~F.data.contains("price")
|
||||
& ~F.data.contains("country")
|
||||
& ~F.data.contains("limit")
|
||||
& ~F.data.contains("desc")
|
||||
& ~F.data.contains("promo"),
|
||||
)
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
|
||||
dp.callback_query.register(toggle_server_trial_assignment, F.data.startswith("admin_server_trial_"))
|
||||
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_promo_"))
|
||||
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
|
||||
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
|
||||
|
||||
@@ -947,7 +1310,9 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(process_server_price_edit, AdminStates.editing_server_price)
|
||||
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
|
||||
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_promo_toggle_"))
|
||||
dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_promo_save_"))
|
||||
|
||||
dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm"))
|
||||
dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_"))
|
||||
|
||||
@@ -4,7 +4,6 @@ from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_subscriptions_keyboard
|
||||
@@ -87,10 +86,6 @@ async def show_subscriptions_menu(
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
|
||||
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
|
||||
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
@@ -277,56 +272,6 @@ async def show_subscriptions_stats(
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_pricing_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = f"""
|
||||
⚙️ <b>Настройки цен</b>
|
||||
|
||||
<b>Периоды подписки:</b>
|
||||
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
|
||||
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
|
||||
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
|
||||
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
|
||||
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
|
||||
|
||||
<b>Трафик-пакеты:</b>
|
||||
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
|
||||
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
|
||||
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
|
||||
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
|
||||
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
|
||||
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
|
||||
|
||||
<b>Дополнительно:</b>
|
||||
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
|
||||
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
|
||||
# ],
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
|
||||
# ],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_countries_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -487,7 +432,6 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
|
||||
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
|
||||
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
|
||||
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
|
||||
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
|
||||
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
|
||||
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import logging
|
||||
import re
|
||||
import html
|
||||
import contextlib
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.states import SupportSettingsStates
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
mode = SupportSettingsService.get_system_mode()
|
||||
menu_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
admin_notif = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
user_notif = SupportSettingsService.get_user_ticket_notifications_enabled()
|
||||
sla_enabled = SupportSettingsService.get_sla_enabled()
|
||||
sla_minutes = SupportSettingsService.get_sla_minutes()
|
||||
|
||||
rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены")
|
||||
status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены")
|
||||
|
||||
def mode_button(label_key: str, default: str, active: bool) -> str:
|
||||
prefix = "🔘" if active else "⚪"
|
||||
return f"{prefix} {texts.t(label_key, default)}"
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'✅' if menu_enabled else '🚫'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}"
|
||||
),
|
||||
callback_data="admin_support_toggle_menu"
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"),
|
||||
callback_data="admin_support_mode_tickets"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"),
|
||||
callback_data="admin_support_mode_contact"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"),
|
||||
callback_data="admin_support_mode_both"
|
||||
),
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"),
|
||||
callback_data="admin_support_edit_desc"
|
||||
)
|
||||
])
|
||||
|
||||
# Notifications block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'🔔' if admin_notif else '🔕'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: "
|
||||
f"{status_enabled if admin_notif else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_admin_notifications"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'🔔' if user_notif else '🔕'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: "
|
||||
f"{status_enabled if user_notif else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_user_notifications"
|
||||
)
|
||||
])
|
||||
|
||||
# SLA block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'⏰' if sla_enabled else '⏹️'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: "
|
||||
f"{status_enabled if sla_enabled else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_sla"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes),
|
||||
callback_data="admin_support_set_sla_minutes"
|
||||
)
|
||||
])
|
||||
|
||||
# Moderators
|
||||
moderators = SupportSettingsService.get_moderators()
|
||||
mod_count = len(moderators)
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑⚖️ Модераторы: {count}").format(count=mod_count),
|
||||
callback_data="admin_support_list_moderators"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"),
|
||||
callback_data="admin_support_add_moderator"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"),
|
||||
callback_data="admin_support_remove_moderator"
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")
|
||||
])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
desc = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 <b>Настройки поддержки</b>") + "\n\n" +
|
||||
texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" +
|
||||
desc,
|
||||
reply_markup=_get_support_settings_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_support_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current = SupportSettingsService.is_support_menu_enabled()
|
||||
SupportSettingsService.set_support_menu_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_admin_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
SupportSettingsService.set_admin_ticket_notifications_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_user_ticket_notifications_enabled()
|
||||
SupportSettingsService.set_user_ticket_notifications_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_sla_enabled()
|
||||
SupportSettingsService.set_sla_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
class SupportAdvancedStates(StatesGroup):
|
||||
waiting_for_sla_minutes = State()
|
||||
waiting_for_moderator_id = State()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_SLA_SETUP_PROMPT",
|
||||
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
minutes = int(text)
|
||||
if minutes <= 0 or minutes > 1440:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)"))
|
||||
return
|
||||
SupportSettingsService.set_sla_minutes(minutes)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT",
|
||||
"🧑⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT",
|
||||
"🧑⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
# We'll reuse the same state; next message will decide action via flag
|
||||
await state.update_data(action="remove_moderator")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
action = data.get("action", "add")
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
tid = int(text)
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)"))
|
||||
return
|
||||
if action == "remove_moderator":
|
||||
ok = SupportSettingsService.remove_moderator(tid)
|
||||
msg = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid)
|
||||
if ok
|
||||
else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора")
|
||||
)
|
||||
else:
|
||||
ok = SupportSettingsService.add_moderator(tid)
|
||||
msg = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid)
|
||||
if ok
|
||||
else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора")
|
||||
)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(msg, reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
moderators = SupportSettingsService.get_moderators()
|
||||
if not moderators:
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True)
|
||||
return
|
||||
text = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑⚖️ <b>Модераторы</b>") +
|
||||
"\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
|
||||
)
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("tickets")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_contact(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("contact")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_both(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("both")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
# plain text for display-only code block
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
|
||||
kb_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"),
|
||||
callback_data="admin_support_send_desc"
|
||||
)
|
||||
])
|
||||
# Подготовим блок контакта (отдельным инлайном)
|
||||
from app.config import settings
|
||||
support_contact_display = settings.get_support_contact_display()
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")
|
||||
])
|
||||
|
||||
text_parts = [
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 <b>Редактирование описания поддержки</b>"),
|
||||
"",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"),
|
||||
"",
|
||||
f"<code>{html.escape(current_desc_plain)}</code>",
|
||||
]
|
||||
if support_contact_display:
|
||||
text_parts += [
|
||||
"",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "<b>Контакт для режима «Контакт»</b>"),
|
||||
f"<code>{html.escape(support_contact_display)}</code>",
|
||||
"",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."),
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_parts),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=kb_rows),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(SupportSettingsStates.waiting_for_desc)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.html_text or message.text
|
||||
SupportSettingsService.set_support_info_text(db_user.language, new_text)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# send plain text for easy copying
|
||||
texts = get_texts(db_user.language)
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
# attach delete button to the sent message
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
if len(current_desc_plain) <= 4000:
|
||||
await callback.message.answer(current_desc_plain, reply_markup=markup)
|
||||
else:
|
||||
# split long messages (attach delete only to the last chunk)
|
||||
chunk = 0
|
||||
while chunk < len(current_desc_plain):
|
||||
next_chunk = current_desc_plain[chunk:chunk+4000]
|
||||
is_last = (chunk + 4000) >= len(current_desc_plain)
|
||||
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
|
||||
chunk += 4000
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже"))
|
||||
|
||||
|
||||
@error_handler
|
||||
async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Allow admins and moderators to delete informational notifications
|
||||
try:
|
||||
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
except Exception:
|
||||
may_delete = False
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
if not may_delete:
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
try:
|
||||
await callback.message.delete()
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено"))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_support_settings, F.data == "admin_support_settings")
|
||||
dp.callback_query.register(toggle_support_menu, F.data == "admin_support_toggle_menu")
|
||||
dp.callback_query.register(set_mode_tickets, F.data == "admin_support_mode_tickets")
|
||||
dp.callback_query.register(set_mode_contact, F.data == "admin_support_mode_contact")
|
||||
dp.callback_query.register(set_mode_both, F.data == "admin_support_mode_both")
|
||||
dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc")
|
||||
dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc")
|
||||
dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg")
|
||||
dp.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications")
|
||||
dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications")
|
||||
dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla")
|
||||
dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes")
|
||||
dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator")
|
||||
dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator")
|
||||
dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators")
|
||||
dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc)
|
||||
dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes)
|
||||
dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOG_PREVIEW_LIMIT = 2300
|
||||
|
||||
|
||||
def _resolve_log_path() -> Path:
|
||||
log_path = Path(settings.LOG_FILE)
|
||||
if not log_path.is_absolute():
|
||||
log_path = Path.cwd() / log_path
|
||||
return log_path
|
||||
|
||||
|
||||
def _format_preview_block(text: str) -> str:
|
||||
escaped_text = escape(text) if text else ""
|
||||
return f"<blockquote expandable><pre><code>{escaped_text}</code></pre></blockquote>"
|
||||
|
||||
|
||||
def _build_logs_message(log_path: Path) -> str:
|
||||
if not log_path.exists():
|
||||
message = (
|
||||
"🧾 <b>Системные логи</b>\n\n"
|
||||
f"Файл <code>{log_path}</code> пока не создан.\n"
|
||||
"Логи появятся автоматически после первой записи."
|
||||
)
|
||||
return message
|
||||
|
||||
try:
|
||||
content = log_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception as error: # pragma: no cover - защита от проблем чтения
|
||||
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
|
||||
message = (
|
||||
"❌ <b>Ошибка чтения логов</b>\n\n"
|
||||
f"Не удалось прочитать файл <code>{log_path}</code>."
|
||||
)
|
||||
return message
|
||||
|
||||
total_length = len(content)
|
||||
stats = log_path.stat()
|
||||
updated_at = datetime.fromtimestamp(stats.st_mtime)
|
||||
|
||||
if not content:
|
||||
preview_text = "Лог-файл пуст."
|
||||
truncated = False
|
||||
else:
|
||||
preview_text = content[-LOG_PREVIEW_LIMIT:]
|
||||
truncated = total_length > LOG_PREVIEW_LIMIT
|
||||
|
||||
details_lines = [
|
||||
"🧾 <b>Системные логи</b>",
|
||||
"",
|
||||
f"📁 <b>Файл:</b> <code>{log_path}</code>",
|
||||
f"🕒 <b>Обновлен:</b> {updated_at.strftime('%d.%m.%Y %H:%M:%S')}",
|
||||
f"🧮 <b>Размер:</b> {total_length} символов",
|
||||
(
|
||||
f"👇 Показаны последние {LOG_PREVIEW_LIMIT} символов."
|
||||
if truncated
|
||||
else "📄 Показано все содержимое файла."
|
||||
),
|
||||
"",
|
||||
_format_preview_block(preview_text),
|
||||
]
|
||||
|
||||
return "\n".join(details_lines)
|
||||
|
||||
|
||||
def _get_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_system_logs_refresh")],
|
||||
[InlineKeyboardButton(text="⬇️ Скачать лог", callback_data="admin_system_logs_download")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_system")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
message = _build_logs_message(log_path)
|
||||
|
||||
reply_markup = _get_logs_keyboard()
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def refresh_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
message = _build_logs_message(log_path)
|
||||
|
||||
reply_markup = _get_logs_keyboard()
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer("🔄 Обновлено")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def download_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
await callback.answer("❌ Лог-файл не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer("⬇️ Отправляю лог...")
|
||||
|
||||
document = FSInputFile(log_path)
|
||||
stats = log_path.stat()
|
||||
updated_at = datetime.fromtimestamp(stats.st_mtime).strftime("%d.%m.%Y %H:%M:%S")
|
||||
caption = (
|
||||
f"🧾 Лог-файл <code>{log_path.name}</code>\n"
|
||||
f"📁 Путь: <code>{log_path}</code>\n"
|
||||
f"🕒 Обновлен: {updated_at}"
|
||||
)
|
||||
await callback.message.answer_document(document=document, caption=caption, parse_mode="HTML")
|
||||
except Exception as error: # pragma: no cover - защита от ошибок отправки
|
||||
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
|
||||
await callback.message.answer(
|
||||
"❌ <b>Не удалось отправить лог-файл</b>\n\n"
|
||||
"Проверьте журналы приложения или повторите попытку позже.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_system_logs,
|
||||
F.data == "admin_system_logs",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
refresh_system_logs,
|
||||
F.data == "admin_system_logs_refresh",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
download_system_logs,
|
||||
F.data == "admin_system_logs_download",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
+1095
-123
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.states import AdminStates
|
||||
from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard
|
||||
@@ -44,16 +43,16 @@ async def show_welcome_text_panel(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👋 Управление приветственным текстом\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
|
||||
f"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -89,11 +88,11 @@ async def show_current_welcome_text(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
current_text = settings['text']
|
||||
is_enabled = settings['is_enabled']
|
||||
|
||||
if not settings['id']:
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
current_text = welcome_settings['text']
|
||||
is_enabled = welcome_settings['is_enabled']
|
||||
|
||||
if not welcome_settings['id']:
|
||||
status = "📝 Используется стандартный текст:"
|
||||
else:
|
||||
status = "📝 Текущий приветственный текст:"
|
||||
@@ -121,7 +120,7 @@ async def show_placeholders_help(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
@@ -137,7 +136,7 @@ async def show_placeholders_help(
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -149,12 +148,12 @@ async def show_formatting_help(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
formatting_info = get_telegram_formatting_info()
|
||||
|
||||
await callback.message.edit_text(
|
||||
formatting_info,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -167,8 +166,8 @@ async def start_edit_welcome_text(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
current_text = settings['text']
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
current_text = welcome_settings['text']
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
|
||||
@@ -206,9 +205,9 @@ async def process_welcome_text_edit(
|
||||
success = await set_welcome_text(db, new_text, db_user.id)
|
||||
|
||||
if success:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
|
||||
@@ -219,14 +218,14 @@ async def process_welcome_text_edit(
|
||||
f"Новый текст:\n"
|
||||
f"<code>{new_text}</code>\n\n"
|
||||
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
await message.answer(
|
||||
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
@@ -242,9 +241,9 @@ async def reset_welcome_text(
|
||||
success = await set_welcome_text(db, default_text, db_user.id)
|
||||
|
||||
if success:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Приветственный текст сброшен на стандартный!\n\n"
|
||||
@@ -252,14 +251,14 @@ async def reset_welcome_text(
|
||||
f"Стандартный текст:\n"
|
||||
f"<code>{default_text}</code>\n\n"
|
||||
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@@ -281,14 +280,14 @@ async def show_preview_welcome_text(
|
||||
test_user = TestUser()
|
||||
preview_text = await get_welcome_text_for_user(db, test_user)
|
||||
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
|
||||
if preview_text:
|
||||
await callback.message.edit_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
|
||||
f"<code>{preview_text}</code>",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
@@ -296,7 +295,7 @@ async def show_preview_welcome_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"🔴 Приветственные сообщения отключены.\n"
|
||||
f"Новые пользователи не будут получать приветственный текст после регистрации.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
+671
-3
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -332,6 +333,84 @@ async def start_yookassa_sbp_payment(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_mulenpay_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
"MULENPAY_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
|
||||
"Оплата происходит через защищенную платформу Mulen Pay."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="mulenpay")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
"🏦 <b>Оплата через PayPalych (СБП)</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
"Оплата проходит через систему быстрых платежей PayPalych."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -523,10 +602,18 @@ async def process_topup_amount(
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
@@ -760,6 +847,341 @@ async def process_yookassa_sbp_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_mulenpay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await message.answer("❌ Оплата через Mulen Pay временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = payment_result.get("payment_url")
|
||||
mulen_payment_id = payment_result.get("mulen_payment_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"MULENPAY_PAY_BUTTON",
|
||||
"💳 Оплатить через Mulen Pay",
|
||||
),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_mulenpay_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id
|
||||
|
||||
message_template = texts.t(
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
payment_id=payment_id_display,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж для пользователя %s: %s₽, ID: %s",
|
||||
db_user.telegram_id,
|
||||
amount_rubles,
|
||||
payment_id_display,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания MulenPay платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_pal24_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
sbp_url = (
|
||||
payment_result.get("sbp_url")
|
||||
or payment_result.get("transfer_url")
|
||||
)
|
||||
card_url = payment_result.get("card_url")
|
||||
fallback_url = (
|
||||
payment_result.get("link_page_url")
|
||||
or payment_result.get("link_url")
|
||||
)
|
||||
|
||||
if not (sbp_url or card_url or fallback_url):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not sbp_url:
|
||||
sbp_url = fallback_url
|
||||
|
||||
bill_id = payment_result.get("bill_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
pay_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
steps: list[str] = []
|
||||
step_counter = 1
|
||||
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_url:
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_url and card_url != sbp_url:
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(card_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
if not pay_buttons and fallback_url:
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=fallback_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
follow_template = texts.t(
|
||||
"PAL24_INSTRUCTION_FOLLOW",
|
||||
"{step}. Следуйте подсказкам платёжной системы",
|
||||
)
|
||||
steps.append(follow_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
confirm_template = texts.t(
|
||||
"PAL24_INSTRUCTION_CONFIRM",
|
||||
"{step}. Подтвердите перевод",
|
||||
)
|
||||
steps.append(confirm_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
success_template = texts.t(
|
||||
"PAL24_INSTRUCTION_COMPLETE",
|
||||
"{step}. Средства зачислятся автоматически",
|
||||
)
|
||||
steps.append(success_template.format(step=step_counter))
|
||||
|
||||
message_template = texts.t(
|
||||
"PAL24_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"🏦 <b>Оплата через PayPalych</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID счета: {bill_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n{steps}\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard_rows = pay_buttons + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
bill_id=bill_id,
|
||||
steps="\n".join(steps),
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s",
|
||||
db_user.telegram_id,
|
||||
amount_kopeks / 100,
|
||||
bill_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -815,6 +1237,144 @@ async def check_yookassa_payment_status(
|
||||
logger.error(f"Ошибка проверки статуса платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_mulenpay_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"created": ("⏳", "Ожидает оплаты"),
|
||||
"processing": ("⌛", "Обрабатывается"),
|
||||
"success": ("✅", "Оплачен"),
|
||||
"canceled": ("❌", "Отменен"),
|
||||
"error": ("⚠️", "Ошибка"),
|
||||
"hold": ("🔒", "Холд"),
|
||||
"unknown": ("❓", "Неизвестно"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
message_lines = [
|
||||
"💳 Статус платежа Mulen Pay:\n\n",
|
||||
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
|
||||
f"📊 Статус: {emoji} {status_text}\n",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"created", "processing"}:
|
||||
message_lines.append(
|
||||
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
|
||||
)
|
||||
if payment.payment_url:
|
||||
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}")
|
||||
elif payment.status in {"canceled", "error"}:
|
||||
message_lines.append(
|
||||
f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
message_text = "".join(message_lines)
|
||||
|
||||
if len(message_text) > 190:
|
||||
await callback.message.answer(message_text)
|
||||
await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True)
|
||||
else:
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса MulenPay: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_pal24_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_pal24_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"NEW": ("⏳", "Ожидает оплаты"),
|
||||
"PROCESS": ("⌛", "Обрабатывается"),
|
||||
"SUCCESS": ("✅", "Оплачен"),
|
||||
"FAIL": ("❌", "Отменен"),
|
||||
"UNDERPAID": ("⚠️", "Недоплата"),
|
||||
"OVERPAID": ("⚠️", "Переплата"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
metadata = payment.metadata_json or {}
|
||||
links_meta = metadata.get("links") if isinstance(metadata, dict) else None
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
|
||||
message_lines = [
|
||||
"🏦 Статус платежа PayPalych:",
|
||||
"",
|
||||
f"🆔 ID счета: {payment.bill_id}",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
|
||||
f"📊 Статус: {emoji} {status_text}",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("")
|
||||
message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"NEW", "PROCESS"}:
|
||||
message_lines.append("")
|
||||
message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
|
||||
if sbp_link:
|
||||
message_lines.append("")
|
||||
message_lines.append(f"🏦 СБП: {sbp_link}")
|
||||
if card_link and card_link != sbp_link:
|
||||
message_lines.append(f"💳 Банковская карта: {card_link}")
|
||||
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
|
||||
message_lines.append("")
|
||||
message_lines.append(
|
||||
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.answer(
|
||||
"\n".join(message_lines),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_cryptobot_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -1085,6 +1645,18 @@ async def handle_quick_amount_selection(
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
@@ -1096,6 +1668,77 @@ async def handle_quick_amount_selection(
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_topup_amount_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
_, method, amount_str = callback.data.split("|", 2)
|
||||
amount_kopeks = int(amount_str)
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный запрос", show_alert=True)
|
||||
return
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
await callback.answer("❌ Некорректная сумма", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
await process_stars_payment_amount(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
elif method == "tribute":
|
||||
await start_tribute_payment(callback, db_user)
|
||||
return
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка быстрого пополнения: {error}")
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
@@ -1132,12 +1775,22 @@ def register_handlers(dp: Dispatcher):
|
||||
start_yookassa_sbp_payment,
|
||||
F.data == "topup_yookassa_sbp"
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
start_mulenpay_payment,
|
||||
F.data == "topup_mulenpay"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_pal24_payment,
|
||||
F.data == "topup_pal24"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
start_tribute_payment,
|
||||
F.data == "topup_tribute"
|
||||
@@ -1168,6 +1821,16 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_cryptobot_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_mulenpay_payment_status,
|
||||
F.data.startswith("check_mulenpay_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_pal24_payment_status,
|
||||
F.data.startswith("check_pal24_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
@@ -1178,3 +1841,8 @@ def register_handlers(dp: Dispatcher):
|
||||
handle_quick_amount_selection,
|
||||
F.data.startswith("quick_amount_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_topup_amount_callback,
|
||||
F.data.startswith("topup_amount|")
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -28,6 +29,26 @@ async def handle_unknown_callback(
|
||||
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
async def handle_noop(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_current_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_cancel(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -83,9 +104,28 @@ def register_handlers(dp: Dispatcher):
|
||||
show_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
# No-op utility handlers used in many keyboards
|
||||
dp.callback_query.register(
|
||||
handle_noop,
|
||||
F.data == "noop"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_current_page,
|
||||
F.data == "current_page"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_cancel,
|
||||
F.data.in_(["cancel", "subscription_cancel"])
|
||||
)
|
||||
|
||||
# Самый последний: ловим любые неизвестные текстовые сообщения
|
||||
# Исключаем специальные сервисные события (например, успешные платежи),
|
||||
# чтобы их обработка не прерывалась общим хендлером неизвестных сообщений
|
||||
dp.message.register(
|
||||
handle_unknown_message,
|
||||
StateFilter(None),
|
||||
F.successful_payment.is_(None)
|
||||
)
|
||||
|
||||
+889
-31
@@ -1,32 +1,154 @@
|
||||
import html
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
from app.database.crud.promo_group import (
|
||||
get_auto_assign_promo_groups,
|
||||
has_auto_assign_promo_groups,
|
||||
)
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.keyboards.inline import (
|
||||
get_main_menu_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_info_menu_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.database.models import User
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_rubles(amount_kopeks: int) -> str:
|
||||
rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
|
||||
if rubles == rubles.to_integral_value():
|
||||
formatted = f"{rubles:,.0f}"
|
||||
else:
|
||||
formatted = f"{rubles:,.2f}"
|
||||
|
||||
return f"{formatted.replace(',', ' ')} ₽"
|
||||
|
||||
|
||||
def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]:
|
||||
discounts: Dict[int, int] = {}
|
||||
raw_discounts = getattr(group, "period_discounts", None)
|
||||
|
||||
if isinstance(raw_discounts, dict):
|
||||
for key, value in raw_discounts.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized_percent = max(0, min(100, percent))
|
||||
if normalized_percent > 0:
|
||||
discounts[period] = normalized_percent
|
||||
|
||||
if group.is_default and settings.is_base_promo_group_period_discount_enabled():
|
||||
try:
|
||||
base_discounts = settings.get_base_promo_group_period_discounts() or {}
|
||||
except Exception:
|
||||
base_discounts = {}
|
||||
|
||||
for key, value in base_discounts.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if period in discounts:
|
||||
continue
|
||||
|
||||
normalized_percent = max(0, min(100, percent))
|
||||
if normalized_percent > 0:
|
||||
discounts[period] = normalized_percent
|
||||
|
||||
return dict(sorted(discounts.items()))
|
||||
|
||||
|
||||
def _build_group_discount_lines(group: PromoGroup, texts, language: str) -> list[str]:
|
||||
lines: list[str] = []
|
||||
|
||||
if getattr(group, "server_discount_percent", 0) > 0:
|
||||
lines.append(
|
||||
texts.t("PROMO_GROUP_DISCOUNT_SERVERS", "🌍 Серверы: {percent}%").format(
|
||||
percent=group.server_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
if getattr(group, "traffic_discount_percent", 0) > 0:
|
||||
lines.append(
|
||||
texts.t("PROMO_GROUP_DISCOUNT_TRAFFIC", "📊 Трафик: {percent}%").format(
|
||||
percent=group.traffic_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
if getattr(group, "device_discount_percent", 0) > 0:
|
||||
lines.append(
|
||||
texts.t("PROMO_GROUP_DISCOUNT_DEVICES", "📱 Доп. устройства: {percent}%").format(
|
||||
percent=group.device_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
period_discounts = _collect_period_discounts(group)
|
||||
|
||||
if period_discounts:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER",
|
||||
"⏳ Скидки за длительный период:",
|
||||
)
|
||||
)
|
||||
|
||||
for period_days, percent in period_discounts.items():
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM",
|
||||
"{period} — {percent}%",
|
||||
).format(
|
||||
period=format_period_description(period_days, language),
|
||||
percent=percent,
|
||||
)
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
skip_callback_answer: bool = False,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from datetime import datetime
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
@@ -41,37 +163,42 @@ async def show_main_menu(
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
is_admin = settings.is_admin(db_user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user: User
|
||||
) -> None:
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
|
||||
if not skip_callback_answer:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
@@ -91,6 +218,642 @@ async def show_service_rules(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_info_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
header = texts.t("MENU_INFO_HEADER", "ℹ️ <b>Инфо</b>")
|
||||
prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:")
|
||||
caption = f"{header}\n\n{prompt}" if prompt else header
|
||||
|
||||
privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language)
|
||||
public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language)
|
||||
faq_enabled = await FaqService.is_enabled(db, db_user.language)
|
||||
promo_groups_available = await has_auto_assign_promo_groups(db)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=caption,
|
||||
keyboard=get_info_menu_keyboard(
|
||||
language=db_user.language,
|
||||
show_privacy_policy=privacy_enabled,
|
||||
show_public_offer=public_offer_enabled,
|
||||
show_faq=faq_enabled,
|
||||
show_promo_groups=promo_groups_available,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_promo_groups_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
promo_groups = await get_auto_assign_promo_groups(db)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]]
|
||||
)
|
||||
|
||||
if not promo_groups:
|
||||
empty_text = texts.t(
|
||||
"PROMO_GROUPS_INFO_EMPTY",
|
||||
"Промогруппы с автовыдачей ещё не настроены.",
|
||||
)
|
||||
header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 <b>Промогруппы</b>")
|
||||
message = f"{header}\n\n{empty_text}" if empty_text else header
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
total_spent_kopeks = await get_user_total_spent_kopeks(db, db_user.id)
|
||||
total_spent_text = _format_rubles(total_spent_kopeks)
|
||||
|
||||
sorted_groups = sorted(
|
||||
promo_groups,
|
||||
key=lambda group: (group.auto_assign_total_spent_kopeks or 0, group.id),
|
||||
)
|
||||
|
||||
achieved_groups: List[PromoGroup] = [
|
||||
group
|
||||
for group in sorted_groups
|
||||
if (group.auto_assign_total_spent_kopeks or 0) > 0
|
||||
and total_spent_kopeks >= (group.auto_assign_total_spent_kopeks or 0)
|
||||
]
|
||||
|
||||
current_group = next(
|
||||
(group for group in sorted_groups if group.id == db_user.promo_group_id),
|
||||
None,
|
||||
)
|
||||
|
||||
if not current_group and achieved_groups:
|
||||
current_group = achieved_groups[-1]
|
||||
|
||||
next_group = next(
|
||||
(
|
||||
group
|
||||
for group in sorted_groups
|
||||
if (group.auto_assign_total_spent_kopeks or 0) > total_spent_kopeks
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 <b>Промогруппы</b>")
|
||||
lines: List[str] = [header, ""]
|
||||
|
||||
spent_line = texts.t(
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT",
|
||||
"💰 Потрачено в боте: {amount}",
|
||||
).format(amount=total_spent_text)
|
||||
lines.append(spent_line)
|
||||
|
||||
if current_group:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL",
|
||||
"🏆 Текущий уровень: {name}",
|
||||
).format(name=html.escape(current_group.name)),
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL",
|
||||
"🏆 Текущий уровень: пока не получен",
|
||||
)
|
||||
)
|
||||
|
||||
if next_group:
|
||||
remaining_kopeks = (next_group.auto_assign_total_spent_kopeks or 0) - total_spent_kopeks
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL",
|
||||
"📈 До уровня «{name}»: осталось {amount}",
|
||||
).format(
|
||||
name=html.escape(next_group.name),
|
||||
amount=_format_rubles(max(remaining_kopeks, 0)),
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL",
|
||||
"🏆 Вы уже получили максимальный уровень скидок!",
|
||||
)
|
||||
)
|
||||
|
||||
lines.extend(["", texts.t("PROMO_GROUPS_INFO_LEVELS_HEADER", "📋 Уровни с автовыдачей:")])
|
||||
|
||||
for group in sorted_groups:
|
||||
threshold = group.auto_assign_total_spent_kopeks or 0
|
||||
status_icon = "✅" if total_spent_kopeks >= threshold else "🔒"
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE",
|
||||
"{status} <b>{name}</b> — от {amount}",
|
||||
).format(
|
||||
status=status_icon,
|
||||
name=html.escape(group.name),
|
||||
amount=_format_rubles(threshold),
|
||||
)
|
||||
)
|
||||
|
||||
discount_lines = _build_group_discount_lines(group, texts, db_user.language)
|
||||
for discount_line in discount_lines:
|
||||
if discount_line:
|
||||
lines.append(f" {discount_line}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
|
||||
message_text = "\n".join(lines)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_faq_pages(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
pages = await FaqService.get_pages(db, db_user.language)
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t("FAQ_NOT_AVAILABLE", "FAQ временно недоступен."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
|
||||
prompt = texts.t("FAQ_PAGES_PROMPT", "Выберите вопрос:" )
|
||||
caption = f"{header}\n\n{prompt}" if prompt else header
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for index, page in enumerate(pages, start=1):
|
||||
raw_title = (page.title or "").strip()
|
||||
if not raw_title:
|
||||
raw_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
|
||||
if len(raw_title) > 60:
|
||||
raw_title = f"{raw_title[:57]}..."
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{index}. {raw_title}",
|
||||
callback_data=f"menu_faq_page:{page.id}:1",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
caption,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_faq_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_data = callback.data or ""
|
||||
parts = raw_data.split(":")
|
||||
|
||||
page_id = None
|
||||
requested_page = 1
|
||||
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
page_id = int(parts[1])
|
||||
except ValueError:
|
||||
page_id = None
|
||||
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
requested_page = int(parts[2])
|
||||
except ValueError:
|
||||
requested_page = 1
|
||||
|
||||
if not page_id:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
page = await FaqService.get_page(db, page_id, db_user.language)
|
||||
|
||||
if not page or not page.is_active:
|
||||
await callback.answer(
|
||||
texts.t("FAQ_PAGE_NOT_AVAILABLE", "Эта страница FAQ недоступна."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content_pages = FaqService.split_content_into_pages(page.content)
|
||||
|
||||
if not content_pages:
|
||||
await callback.answer(
|
||||
texts.t("FAQ_PAGE_EMPTY", "Текст для этой страницы ещё не добавлен."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
total_pages = len(content_pages)
|
||||
current_page = max(1, min(requested_page, total_pages))
|
||||
|
||||
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
|
||||
title_template = texts.t("FAQ_PAGE_TITLE", "<b>{title}</b>")
|
||||
page_title = (page.title or "").strip()
|
||||
if not page_title:
|
||||
page_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
|
||||
title_block = title_template.format(title=html.escape(page_title))
|
||||
|
||||
body = content_pages[current_page - 1]
|
||||
|
||||
footer_template = texts.t(
|
||||
"FAQ_PAGE_FOOTER",
|
||||
"Страница {current} из {total}",
|
||||
)
|
||||
footer = ""
|
||||
if total_pages > 1 and footer_template:
|
||||
try:
|
||||
footer = footer_template.format(current=current_page, total=total_pages)
|
||||
except Exception:
|
||||
footer = f"{current_page}/{total_pages}"
|
||||
|
||||
parts_to_join = [header, title_block]
|
||||
if body:
|
||||
parts_to_join.append(body)
|
||||
if footer:
|
||||
parts_to_join.append(f"<code>{footer}</code>")
|
||||
|
||||
message_text = "\n\n".join(segment for segment in parts_to_join if segment)
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: list[types.InlineKeyboardButton] = []
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"menu_faq_page:{page.id}:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="noop",
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"menu_faq_page:{page.id}:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("FAQ_BACK_TO_LIST", "⬅️ К списку FAQ"),
|
||||
callback_data="menu_faq",
|
||||
)
|
||||
])
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
async def show_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
if callback.data and ":" in callback.data:
|
||||
try:
|
||||
raw_page = int(callback.data.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raw_page = 1
|
||||
|
||||
if raw_page < 1:
|
||||
raw_page = 1
|
||||
|
||||
policy = await PrivacyPolicyService.get_active_policy(db, db_user.language)
|
||||
|
||||
if not policy:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE",
|
||||
"Политика конфиденциальности временно недоступна.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
pages = PrivacyPolicyService.split_content_into_pages(policy.content)
|
||||
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PRIVACY_POLICY_EMPTY_ALERT",
|
||||
"Политика конфиденциальности ещё не заполнена.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
total_pages = len(pages)
|
||||
current_page = raw_page if raw_page <= total_pages else total_pages
|
||||
|
||||
header = texts.t(
|
||||
"PRIVACY_POLICY_HEADER",
|
||||
"🛡️ <b>Политика конфиденциальности</b>",
|
||||
)
|
||||
body = pages[current_page - 1]
|
||||
|
||||
footer_template = texts.t(
|
||||
"PRIVACY_POLICY_PAGE_INFO",
|
||||
"Страница {current} из {total}",
|
||||
)
|
||||
footer = ""
|
||||
if total_pages > 1 and footer_template:
|
||||
try:
|
||||
footer = footer_template.format(current=current_page, total=total_pages)
|
||||
except Exception:
|
||||
footer = f"{current_page}/{total_pages}"
|
||||
|
||||
message_text = header
|
||||
if body:
|
||||
message_text += f"\n\n{body}"
|
||||
if footer:
|
||||
message_text += f"\n\n<code>{footer}</code>"
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: list[types.InlineKeyboardButton] = []
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"menu_privacy_policy:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="noop",
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"menu_privacy_policy:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
if callback.data and ":" in callback.data:
|
||||
try:
|
||||
raw_page = int(callback.data.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raw_page = 1
|
||||
|
||||
if raw_page < 1:
|
||||
raw_page = 1
|
||||
|
||||
offer = await PublicOfferService.get_active_offer(db, db_user.language)
|
||||
|
||||
if not offer:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE",
|
||||
"Публичная оферта временно недоступна.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
pages = PublicOfferService.split_content_into_pages(offer.content)
|
||||
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PUBLIC_OFFER_EMPTY_ALERT",
|
||||
"Публичная оферта ещё не заполнена.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
total_pages = len(pages)
|
||||
current_page = raw_page if raw_page <= total_pages else total_pages
|
||||
|
||||
header = texts.t(
|
||||
"PUBLIC_OFFER_HEADER",
|
||||
"📄 <b>Публичная оферта</b>",
|
||||
)
|
||||
body = pages[current_page - 1]
|
||||
|
||||
footer_template = texts.t(
|
||||
"PUBLIC_OFFER_PAGE_INFO",
|
||||
"Страница {current} из {total}",
|
||||
)
|
||||
footer = ""
|
||||
if total_pages > 1 and footer_template:
|
||||
try:
|
||||
footer = footer_template.format(current=current_page, total=total_pages)
|
||||
except Exception:
|
||||
footer = f"{current_page}/{total_pages}"
|
||||
|
||||
message_text = header
|
||||
if body:
|
||||
message_text += f"\n\n{body}"
|
||||
if footer:
|
||||
message_text += f"\n\n<code>{footer}</code>"
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: list[types.InlineKeyboardButton] = []
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"menu_public_offer:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="noop",
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"menu_public_offer:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_language_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("LANGUAGE_PROMPT", "🌐 Выберите язык интерфейса:"),
|
||||
keyboard=get_language_selection_keyboard(
|
||||
current_language=db_user.language,
|
||||
include_back=True,
|
||||
language=db_user.language,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_language_change(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
selected_raw = (callback.data or "").split(":", 1)[-1]
|
||||
normalized_selected = selected_raw.strip().lower()
|
||||
|
||||
available_map = {
|
||||
lang.strip().lower(): lang.strip()
|
||||
for lang in settings.get_available_languages()
|
||||
if isinstance(lang, str) and lang.strip()
|
||||
}
|
||||
|
||||
if normalized_selected not in available_map:
|
||||
await callback.answer("❌ Unsupported language", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_language = available_map[normalized_selected].lower()
|
||||
|
||||
if db_user.language.lower() == normalized_selected:
|
||||
await show_main_menu(
|
||||
callback,
|
||||
db_user,
|
||||
db,
|
||||
skip_callback_answer=True,
|
||||
)
|
||||
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
|
||||
return
|
||||
|
||||
updated_user = await update_user(db, db_user, language=resolved_language)
|
||||
texts = get_texts(updated_user.language)
|
||||
|
||||
await show_main_menu(
|
||||
callback,
|
||||
updated_user,
|
||||
db,
|
||||
skip_callback_answer=True,
|
||||
)
|
||||
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
|
||||
|
||||
|
||||
async def handle_back_to_menu(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -112,19 +875,34 @@ async def handle_back_to_menu(
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
is_admin = settings.is_admin(db_user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -209,9 +987,38 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
|
||||
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
|
||||
|
||||
info_sections: list[str] = []
|
||||
|
||||
try:
|
||||
promo_hint = await build_promo_offer_hint(db, user, texts)
|
||||
if promo_hint:
|
||||
info_sections.append(promo_hint.strip())
|
||||
except Exception as hint_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
hint_error,
|
||||
)
|
||||
|
||||
try:
|
||||
test_access_hint = await build_test_access_hint(db, user, texts)
|
||||
if test_access_hint:
|
||||
info_sections.append(test_access_hint.strip())
|
||||
except Exception as test_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
test_error,
|
||||
)
|
||||
|
||||
if info_sections:
|
||||
extra_block = "\n\n".join(section for section in info_sections if section)
|
||||
if extra_block:
|
||||
base_text = _insert_random_message(base_text, extra_block, action_prompt)
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
@@ -234,3 +1041,54 @@ def register_handlers(dp: Dispatcher):
|
||||
show_service_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_info_menu,
|
||||
F.data == "menu_info",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promo_groups_info,
|
||||
F.data == "menu_info_promo_groups",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_faq_pages,
|
||||
F.data == "menu_faq",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_faq_page,
|
||||
F.data.startswith("menu_faq_page:"),
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy,
|
||||
F.data == "menu_privacy_policy",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy,
|
||||
F.data.startswith("menu_privacy_policy:"),
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_public_offer,
|
||||
F.data == "menu_public_offer",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_public_offer,
|
||||
F.data.startswith("menu_public_offer:"),
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_language_menu,
|
||||
F.data == "menu_language"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_change,
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(None)
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,6 +62,21 @@ async def process_promocode(
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
db_user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления об активации промокода %s: %s",
|
||||
code,
|
||||
notify_error,
|
||||
)
|
||||
else:
|
||||
error_messages = {
|
||||
"not_found": texts.PROMOCODE_INVALID,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_server_status_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.server_status_service import (
|
||||
ServerStatusEntry,
|
||||
ServerStatusError,
|
||||
ServerStatusService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_status_service = ServerStatusService()
|
||||
|
||||
|
||||
async def show_server_status(callback: types.CallbackQuery, db_user: User) -> None:
|
||||
await _render_server_status(callback, db_user, page=1)
|
||||
|
||||
|
||||
async def change_server_status_page(callback: types.CallbackQuery, db_user: User) -> None:
|
||||
try:
|
||||
_, page_str = callback.data.split(":", 1)
|
||||
page = int(page_str)
|
||||
except (ValueError, AttributeError, IndexError):
|
||||
page = 1
|
||||
|
||||
await _render_server_status(callback, db_user, page=page)
|
||||
|
||||
|
||||
async def _render_server_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
page: int = 1,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if settings.get_server_status_mode() != "xray":
|
||||
await callback.answer(texts.t("SERVER_STATUS_NOT_CONFIGURED", "Функция недоступна."), show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
servers = await _status_service.get_servers()
|
||||
except ServerStatusError as error:
|
||||
logger.warning("Server status error: %s", error)
|
||||
await callback.answer(
|
||||
texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error("Unexpected server status error: %s", error)
|
||||
await callback.answer(
|
||||
texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
message, total_pages, current_page = _build_status_message(servers, texts, page)
|
||||
keyboard = get_server_status_keyboard(db_user.language, current_page, total_pages)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _build_status_message(
|
||||
servers: List[ServerStatusEntry],
|
||||
texts,
|
||||
page: int,
|
||||
) -> Tuple[str, int, int]:
|
||||
total_servers = len(servers)
|
||||
online_servers = [server for server in servers if server.is_online]
|
||||
offline_servers = [server for server in servers if not server.is_online]
|
||||
|
||||
items_per_page = settings.get_server_status_items_per_page()
|
||||
pages = _split_into_pages(online_servers, offline_servers, items_per_page)
|
||||
|
||||
total_pages = max(1, len(pages))
|
||||
current_index = min(max(page - 1, 0), total_pages - 1)
|
||||
|
||||
current_online, current_offline = pages[current_index] if pages else ([], [])
|
||||
|
||||
lines: List[str] = [texts.t("SERVER_STATUS_TITLE", "📊 <b>Статус серверов</b>")]
|
||||
|
||||
if total_servers == 0:
|
||||
lines.append("")
|
||||
lines.append(texts.t("SERVER_STATUS_NO_SERVERS", "Нет данных о серверах."))
|
||||
message = "\n".join(lines).strip()
|
||||
return message, 1, 1
|
||||
|
||||
summary = texts.t(
|
||||
"SERVER_STATUS_SUMMARY",
|
||||
"Всего серверов: {total} (в сети: {online}, вне сети: {offline})",
|
||||
).format(
|
||||
total=total_servers,
|
||||
online=len(online_servers),
|
||||
offline=len(offline_servers),
|
||||
)
|
||||
|
||||
updated_at = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
summary,
|
||||
texts.t("SERVER_STATUS_UPDATED_AT", "⏱ Обновлено: {time}").format(time=updated_at),
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
if current_online:
|
||||
lines.append(texts.t("SERVER_STATUS_AVAILABLE", "✅ <b>Доступны</b>"))
|
||||
lines.extend(_format_server_lines(current_online, texts, online=True))
|
||||
lines.append("")
|
||||
|
||||
if current_offline:
|
||||
lines.append(texts.t("SERVER_STATUS_UNAVAILABLE", "❌ <b>Недоступны</b>"))
|
||||
lines.extend(_format_server_lines(current_offline, texts, online=False))
|
||||
lines.append("")
|
||||
|
||||
if total_pages > 1:
|
||||
lines.append(
|
||||
texts.t("SERVER_STATUS_PAGINATION", "Страница {current} из {total}").format(
|
||||
current=current_index + 1,
|
||||
total=total_pages,
|
||||
)
|
||||
)
|
||||
|
||||
message = "\n".join(line for line in lines if line is not None)
|
||||
message = message.strip()
|
||||
return message, total_pages, current_index + 1
|
||||
|
||||
|
||||
def _split_into_pages(
|
||||
online: List[ServerStatusEntry],
|
||||
offline: List[ServerStatusEntry],
|
||||
items_per_page: int,
|
||||
) -> List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]]:
|
||||
if not online and not offline:
|
||||
return [([], [])]
|
||||
|
||||
pages: List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]] = []
|
||||
online_index = 0
|
||||
offline_index = 0
|
||||
|
||||
while online_index < len(online) or offline_index < len(offline):
|
||||
current_online: List[ServerStatusEntry] = []
|
||||
current_offline: List[ServerStatusEntry] = []
|
||||
remaining = max(1, items_per_page)
|
||||
|
||||
while remaining > 0 and online_index < len(online):
|
||||
current_online.append(online[online_index])
|
||||
online_index += 1
|
||||
remaining -= 1
|
||||
|
||||
while remaining > 0 and offline_index < len(offline):
|
||||
current_offline.append(offline[offline_index])
|
||||
offline_index += 1
|
||||
remaining -= 1
|
||||
|
||||
pages.append((current_online, current_offline))
|
||||
|
||||
return pages if pages else [([], [])]
|
||||
|
||||
|
||||
def _format_server_lines(
|
||||
servers: List[ServerStatusEntry],
|
||||
texts,
|
||||
*,
|
||||
online: bool,
|
||||
) -> List[str]:
|
||||
lines: List[str] = []
|
||||
for server in servers:
|
||||
latency_text: str
|
||||
if online:
|
||||
if server.latency_ms and server.latency_ms > 0:
|
||||
latency_text = texts.t("SERVER_STATUS_LATENCY", "{latency} мс").format(
|
||||
latency=server.latency_ms
|
||||
)
|
||||
else:
|
||||
latency_text = texts.t("SERVER_STATUS_LATENCY_UNKNOWN", "нет данных")
|
||||
else:
|
||||
latency_text = texts.t("SERVER_STATUS_OFFLINE", "нет ответа")
|
||||
|
||||
name = server.display_name or server.name
|
||||
flag_prefix = f"{server.flag} " if server.flag else ""
|
||||
server_line = f"{flag_prefix}{name} — {latency_text}"
|
||||
lines.append(f"<blockquote>{server_line}</blockquote>")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_server_status,
|
||||
F.data == "menu_server_status",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
change_server_status_page,
|
||||
F.data.startswith("server_status_page:"),
|
||||
)
|
||||
|
||||
@@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
|
||||
+434
-68
@@ -17,16 +17,28 @@ from app.database.crud.campaign import (
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_by_id,
|
||||
)
|
||||
from app.database.models import UserStatus
|
||||
from app.database.models import UserStatus, SubscriptionStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
|
||||
get_rules_keyboard,
|
||||
get_main_menu_keyboard,
|
||||
get_post_registration_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
)
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -71,8 +83,8 @@ async def _apply_campaign_bonus_if_needed(
|
||||
|
||||
|
||||
async def handle_potential_referral_code(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
@@ -132,7 +144,80 @@ async def handle_potential_referral_code(
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _get_language_prompt_text() -> str:
|
||||
return "🌐 Выберите язык / Choose your language:"
|
||||
|
||||
|
||||
async def _prompt_language_selection(message: types.Message, state: FSMContext) -> None:
|
||||
logger.info(f"🌐 LANGUAGE: Запрос выбора языка для пользователя {message.from_user.id}")
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_language)
|
||||
await message.answer(
|
||||
_get_language_prompt_text(),
|
||||
reply_markup=get_language_selection_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
async def _continue_registration_after_language(
|
||||
*,
|
||||
message: types.Message | None,
|
||||
callback: types.CallbackQuery | None,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
target_message = callback.message if callback else message
|
||||
if not target_message:
|
||||
logger.warning("⚠️ LANGUAGE: Нет доступного сообщения для продолжения регистрации")
|
||||
return
|
||||
|
||||
async def _complete_registration_wrapper():
|
||||
if callback:
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ LANGUAGE: SKIP_RULES_ACCEPT включен - пропускаем правила")
|
||||
|
||||
if data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ LANGUAGE: Реферер найден: {referrer.id}")
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
await _complete_registration_wrapper()
|
||||
else:
|
||||
try:
|
||||
await target_message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info("🔍 LANGUAGE: Ожидание ввода реферального кода")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде после выбора языка: {error}")
|
||||
await _complete_registration_wrapper()
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await target_message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 LANGUAGE: Правила отправлены после выбора языка")
|
||||
|
||||
|
||||
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
|
||||
@@ -164,6 +249,22 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
await state.update_data(referral_code=referral_code)
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if campaign:
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_campaign_link_visit_notification(
|
||||
db,
|
||||
message.from_user,
|
||||
campaign,
|
||||
user,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления о переходе по кампании %s: %s",
|
||||
campaign.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
|
||||
@@ -228,17 +329,31 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -257,6 +372,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
from sqlalchemy import delete
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
@@ -308,55 +424,128 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
else:
|
||||
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
logger.info(f"💾 Установлен язык по умолчанию: {language}")
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
|
||||
if data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
if not data.get('language'):
|
||||
if settings.is_language_selection_enabled():
|
||||
await _prompt_language_selection(message, state)
|
||||
return
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
await complete_registration(message, state, db)
|
||||
else:
|
||||
try:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
default_language = (
|
||||
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
|
||||
if isinstance(settings.DEFAULT_LANGUAGE, str)
|
||||
else DEFAULT_LANGUAGE
|
||||
)
|
||||
normalized_default = default_language.split("-")[0].lower()
|
||||
data['language'] = normalized_default
|
||||
await state.set_data(data)
|
||||
logger.info(
|
||||
"🌐 LANGUAGE: выбор языка отключен, устанавливаем язык по умолчанию '%s'",
|
||||
normalized_default,
|
||||
)
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=message,
|
||||
callback=None,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def process_language_selection(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
logger.info(
|
||||
f"🌐 LANGUAGE: Пользователь {callback.from_user.id} выбрал язык ({callback.data})"
|
||||
)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
data = await state.get_data() or {}
|
||||
default_language = (
|
||||
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
|
||||
if isinstance(settings.DEFAULT_LANGUAGE, str)
|
||||
else DEFAULT_LANGUAGE
|
||||
)
|
||||
normalized_default = default_language.split("-")[0].lower()
|
||||
data['language'] = normalized_default
|
||||
await state.set_data(data)
|
||||
|
||||
texts = get_texts(normalized_default)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info("🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration(message, state, db)
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=None,
|
||||
callback=callback,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"📋 Правила отправлены")
|
||||
selected_raw = (callback.data or "").split(":", 1)[-1]
|
||||
normalized_selected = selected_raw.strip().lower()
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Установлено состояние: {current_state}")
|
||||
available_map = {
|
||||
lang.strip().lower(): lang.strip()
|
||||
for lang in settings.get_available_languages()
|
||||
if isinstance(lang, str) and lang.strip()
|
||||
}
|
||||
|
||||
if normalized_selected not in available_map:
|
||||
logger.warning(
|
||||
f"⚠️ LANGUAGE: Выбран недоступный язык '{normalized_selected}' пользователем {callback.from_user.id}"
|
||||
)
|
||||
await callback.answer("❌ Unsupported language", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_language = available_map[normalized_selected].lower()
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = resolved_language
|
||||
await state.set_data(data)
|
||||
|
||||
texts = get_texts(resolved_language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка: {error}")
|
||||
await callback.message.answer(
|
||||
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=None,
|
||||
callback=callback,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def process_rules_accept(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
@@ -561,18 +750,33 @@ async def complete_registration_from_callback(
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
|
||||
is_admin = settings.is_admin(existing_user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(existing_user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -591,7 +795,16 @@ async def complete_registration_from_callback(
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
campaign_id = data.get('campaign_id')
|
||||
is_new_user_registration = (
|
||||
existing_user is None
|
||||
or (
|
||||
existing_user
|
||||
and existing_user.status == UserStatus.DELETED.value
|
||||
)
|
||||
)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
@@ -689,7 +902,16 @@ async def complete_registration_from_callback(
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, callback.from_user)
|
||||
|
||||
if offer_text:
|
||||
skip_welcome_offer = bool(campaign_id) and is_new_user_registration
|
||||
|
||||
if skip_welcome_offer:
|
||||
logger.info(
|
||||
"ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s",
|
||||
user.telegram_id,
|
||||
campaign_id,
|
||||
)
|
||||
|
||||
if offer_text and not skip_welcome_offer:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
@@ -708,18 +930,33 @@ async def complete_registration_from_callback(
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -767,18 +1004,33 @@ async def complete_registration(
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
|
||||
is_admin = settings.is_admin(existing_user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(existing_user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -797,7 +1049,16 @@ async def complete_registration(
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
campaign_id = data.get('campaign_id')
|
||||
is_new_user_registration = (
|
||||
existing_user is None
|
||||
or (
|
||||
existing_user
|
||||
and existing_user.status == UserStatus.DELETED.value
|
||||
)
|
||||
)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
@@ -895,7 +1156,16 @@ async def complete_registration(
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, message.from_user)
|
||||
|
||||
if offer_text:
|
||||
skip_welcome_offer = bool(campaign_id) and is_new_user_registration
|
||||
|
||||
if skip_welcome_offer:
|
||||
logger.info(
|
||||
"ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s",
|
||||
user.telegram_id,
|
||||
campaign_id,
|
||||
)
|
||||
|
||||
if offer_text and not skip_welcome_offer:
|
||||
try:
|
||||
await message.answer(
|
||||
offer_text,
|
||||
@@ -914,18 +1184,33 @@ async def complete_registration(
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -1033,13 +1318,43 @@ def get_referral_code_keyboard(language: str):
|
||||
|
||||
async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
import html
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
user_name=html.escape(user.full_name or ""),
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
|
||||
|
||||
info_sections: list[str] = []
|
||||
|
||||
try:
|
||||
promo_hint = await build_promo_offer_hint(db, user, texts)
|
||||
if promo_hint:
|
||||
info_sections.append(promo_hint.strip())
|
||||
except Exception as hint_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
hint_error,
|
||||
)
|
||||
|
||||
try:
|
||||
test_access_hint = await build_test_access_hint(db, user, texts)
|
||||
if test_access_hint:
|
||||
info_sections.append(test_access_hint.strip())
|
||||
except Exception as test_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
test_error,
|
||||
)
|
||||
|
||||
if info_sections:
|
||||
extra_block = "\n\n".join(section for section in info_sections if section)
|
||||
if extra_block:
|
||||
base_text = _insert_random_message(base_text, extra_block, action_prompt)
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
@@ -1052,8 +1367,9 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
|
||||
import html
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
user_name=html.escape(user_name or ""),
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
)
|
||||
|
||||
@@ -1105,6 +1421,34 @@ async def required_sub_channel_check(
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
if user and user.subscription:
|
||||
subscription = user.subscription
|
||||
if (
|
||||
subscription.is_trial
|
||||
and subscription.status == SubscriptionStatus.DISABLED.value
|
||||
):
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
logger.info(
|
||||
"✅ Триальная подписка пользователя %s восстановлена после подтверждения подписки на канал",
|
||||
user.telegram_id,
|
||||
)
|
||||
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if user.remnawave_uuid:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
"❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя %s: %s",
|
||||
user.telegram_id if user else query.from_user.id,
|
||||
api_error,
|
||||
)
|
||||
|
||||
await query.answer(
|
||||
texts.t("CHANNEL_SUBSCRIBE_THANKS", "✅ Спасибо за подписку"),
|
||||
show_alert=True,
|
||||
@@ -1124,14 +1468,29 @@ async def required_sub_channel_check(
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
@@ -1226,7 +1585,14 @@ def register_handlers(dp: Dispatcher):
|
||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_selection,
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(RegistrationStates.waiting_for_language)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_language_selection")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_referral_code_skip,
|
||||
F.data == "referral_skip",
|
||||
|
||||
+3293
-1355
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_support_keyboard
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,10 +18,12 @@ async def show_support_info(
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SUPPORT_INFO,
|
||||
reply_markup=get_support_keyboard(db_user.language)
|
||||
support_info = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=support_info,
|
||||
keyboard=get_support_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1071
-306
File diff suppressed because it is too large
Load Diff
+853
-112
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,26 @@ WELCOME: |
|
||||
|
||||
MENU:
|
||||
BALANCE: "Balance"
|
||||
SERVER_STATUS: "Server status"
|
||||
SUBSCRIPTION: "Subscription"
|
||||
|
||||
SERVER_STATUS:
|
||||
TITLE: "📊 <b>Server status</b>"
|
||||
SUMMARY: "Total servers: {total} (online: {online}, offline: {offline})"
|
||||
AVAILABLE: "✅ <b>Online</b>"
|
||||
UNAVAILABLE: "❌ <b>Offline</b>"
|
||||
LATENCY: "{latency} ms"
|
||||
LATENCY_UNKNOWN: "no data"
|
||||
OFFLINE: "no response"
|
||||
NO_SERVERS: "No server data available."
|
||||
PAGINATION: "Page {current} of {total}"
|
||||
PREV_PAGE: "⬅️ Back"
|
||||
NEXT_PAGE: "Next ➡️"
|
||||
REFRESH: "🔄 Refresh"
|
||||
ERROR_SHORT: "Failed to fetch data"
|
||||
NOT_CONFIGURED: "Feature is not available."
|
||||
UPDATED_AT: "⏱ Updated at: {time}"
|
||||
|
||||
RULES_TEXT: |
|
||||
Remnawave service rules:
|
||||
1. Follow the law of your jurisdiction.
|
||||
|
||||
@@ -6,8 +6,26 @@ WELCOME: |
|
||||
|
||||
MENU:
|
||||
BALANCE: "Баланс"
|
||||
SERVER_STATUS: "Статус серверов"
|
||||
SUBSCRIPTION: "Подписка"
|
||||
|
||||
SERVER_STATUS:
|
||||
TITLE: "📊 <b>Статус серверов</b>"
|
||||
SUMMARY: "Всего серверов: {total} (в сети: {online}, вне сети: {offline})"
|
||||
AVAILABLE: "✅ <b>Доступны</b>"
|
||||
UNAVAILABLE: "❌ <b>Недоступны</b>"
|
||||
LATENCY: "{latency} мс"
|
||||
LATENCY_UNKNOWN: "нет данных"
|
||||
OFFLINE: "нет ответа"
|
||||
NO_SERVERS: "Нет данных о серверах."
|
||||
PAGINATION: "Страница {current} из {total}"
|
||||
PREV_PAGE: "⬅️ Назад"
|
||||
NEXT_PAGE: "Вперед ➡️"
|
||||
REFRESH: "🔄 Обновить"
|
||||
ERROR_SHORT: "Не удалось получить данные"
|
||||
NOT_CONFIGURED: "Функция недоступна."
|
||||
UPDATED_AT: "⏱ Обновлено: {time}"
|
||||
|
||||
RULES_TEXT: |
|
||||
Правила сервиса Remnawave:
|
||||
1. Следуйте законам своей страны.
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
{
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Server management is unavailable — only one server is accessible",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Manage subscription countries</b>\n\n📋 <b>Current countries ({current_count}):</b>\n{current_list}\n\n💡 <b>How it works:</b>\n✅ — currently connected\n➕ — will be added (paid)\n➖ — will be removed (free)\n⚪ — not selected\n\n⚠️ <b>Important:</b> Reconnecting removed countries will be charged again!",
|
||||
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
|
||||
"PAID_FEATURE_ONLY": "⚠ This feature is available only for paid subscriptions",
|
||||
"PAID_FEATURE_ONLY_SHORT": "⚠ Paid subscriptions only",
|
||||
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ This server is not available for your promo group",
|
||||
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ No changes detected",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
|
||||
"COUNTRY_CHANGES_ADDED_HEADER": "➕ <b>Added countries:</b>\n",
|
||||
"COUNTRY_CHANGES_CHARGED": "💰 Charged: {amount} (for {months} mo)",
|
||||
"COUNTRY_CHANGES_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Removed countries:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Reconnecting later will be charged",
|
||||
"COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 <b>Active countries:</b> {count}",
|
||||
"ADMIN_MAIN_MENU": "🏠 Main menu",
|
||||
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
|
||||
"AUTOPAY_BUTTON": "💳 Auto payment",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
|
||||
"AUTOPAY_STATUS_ENABLED": "enabled",
|
||||
"AUTOPAY_STATUS_DISABLED": "disabled",
|
||||
"AUTOPAY_MENU_TEXT": "💳 <b>Auto payment</b>\n\n📊 <b>Status:</b> {status}\n⏰ <b>Charge:</b> {days} days before expiry\n\nChoose an action:",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
|
||||
"AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Choose how many days before expiry to charge the payment:",
|
||||
"AUTOPAY_DAYS_SET": "✅ Set to {days} days!",
|
||||
"BACK": "⬅️ Back",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
|
||||
"CANCEL": "❌ Cancel",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
|
||||
"CHECK_STATUS_BUTTON": "📊 Check status",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
|
||||
"CONFIRM": "✅ Confirm",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
|
||||
"CONNECT_BUTTON": "🔗 Connect",
|
||||
"HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ",
|
||||
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Download Happ</b>\nChoose your device:",
|
||||
"HAPP_PLATFORM_IOS": "🍎 iOS",
|
||||
"HAPP_PLATFORM_ANDROID": "🤖 Android",
|
||||
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
|
||||
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
|
||||
"HAPP_PLATFORM_PC": "💻 PC",
|
||||
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:",
|
||||
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured",
|
||||
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link",
|
||||
"CONTINUE": "➡️ Continue",
|
||||
"CONTINUE_BUTTON": "➡️ Continue",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
|
||||
@@ -37,12 +69,36 @@
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
|
||||
"LANGUAGE_PROMPT": "🌐 Choose the interface language:",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Test servers active: {servers}",
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n<code>{bar}</code>",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"DEVICE_UUID_NOT_FOUND": "❌ User UUID not found",
|
||||
"DEVICE_NONE_CONNECTED": "ℹ️ You have no connected devices",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
|
||||
"DEVICE_MANAGEMENT_OVERVIEW": "🔄 <b>Device management</b>\n\n📊 Total connected: {total} devices\n📄 Page {page} of {pages}\n\n",
|
||||
"DEVICE_MANAGEMENT_CONNECTED_HEADER": "<b>Connected devices:</b>\n",
|
||||
"DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
|
||||
"DEVICE_MANAGEMENT_ACTIONS": "\n💡 <b>Actions:</b>\n• Select a device to reset\n• Or reset all devices at once",
|
||||
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
|
||||
"DEVICE_PAGE_LOAD_ERROR": "❌ Failed to open the page",
|
||||
"DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request",
|
||||
"DEVICE_RESET_PARSE_ERROR": "❌ Failed to process the request",
|
||||
"DEVICE_RESET_SUCCESS": "✅ Device {device} has been reset!",
|
||||
"DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset",
|
||||
"DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID",
|
||||
"DEVICE_RESET_NOT_FOUND": "❌ Device not found",
|
||||
"DEVICE_RESET_ERROR": "❌ Failed to reset the device",
|
||||
"DEVICE_LIST_FETCH_ERROR": "❌ Failed to load device list",
|
||||
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>All devices have been reset!</b>\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect",
|
||||
"DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ <b>Devices reset partially</b>\n\n✅ Removed: {success} devices\n❌ Failed to remove: {failed} devices\n\nTry again or contact support.",
|
||||
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Couldn't reset devices</b>\n\nPlease try again later or contact support.\n\nTotal devices: {total}",
|
||||
"MENU_BALANCE": "💰 Balance",
|
||||
"MENU_SUBSCRIPTION": "📱 Subscription",
|
||||
"MENU_TRIAL": "🎁 Trial subscription",
|
||||
@@ -85,6 +141,9 @@
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link",
|
||||
"SKIP_BUTTON": "Skip ➡️",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
|
||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Subscription settings</b>\n\n📊 <b>Current parameters:</b>\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
|
||||
"SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ You don't have an active subscription!",
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days",
|
||||
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
|
||||
@@ -126,7 +185,16 @@
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Promo group discounts:",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Servers: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Traffic: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Devices: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Add-on discounts: <b>enabled</b>",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Add-on discounts: <b>disabled</b>",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Add-on purchase discounts are <b>enabled</b>.",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Add-on purchase discounts are <b>disabled</b>.",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
@@ -141,6 +209,22 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
|
||||
"ADMIN_USER_MANAGEMENT_PROFILE": "👤 <b>User management</b>\n\n<b>Main information:</b>\n• Name: {name}\n• ID: <code>{telegram_id}</code>\n• Username: {username}\n• Status: {status}\n• Language: {language}\n\n<b>Finances:</b>\n• Balance: {balance}\n• Transactions: {transactions}\n\n<b>Activity:</b>\n• Registration: {registration}\n• Last activity: {last_activity}\n• Days since registration: {registration_days}",
|
||||
"ADMIN_USER_USERNAME_NOT_SET": "not set",
|
||||
"ADMIN_USER_STATUS_ACTIVE": "✅ Active",
|
||||
"ADMIN_USER_STATUS_BLOCKED": "🚫 Blocked",
|
||||
"ADMIN_USER_STATUS_DELETED": "🗑️ Deleted",
|
||||
"ADMIN_USER_STATUS_UNKNOWN": "❓ Unknown",
|
||||
"ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Unknown",
|
||||
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Trial",
|
||||
"ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Paid",
|
||||
"ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Active",
|
||||
"ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Inactive",
|
||||
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} GB",
|
||||
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "<b>Subscription:</b>\n• Type: {type}\n• Status: {status}\n• Until: {end_date}\n• Traffic: {traffic}\n• Devices: {devices}\n• Countries: {countries}",
|
||||
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Subscription:</b> None",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Promo group:</b>\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Promo group:</b> Not assigned",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Promo group:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
|
||||
@@ -193,20 +277,48 @@
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
|
||||
"CONTACT_SUPPORT": "💬 Contact support",
|
||||
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram blocked the contact request button because of your privacy settings.</b>\n\nAllow sharing your contact details in Telegram settings or send the required information manually in the chat.",
|
||||
"CREATE_INVITE": "📝 Create invite",
|
||||
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
|
||||
"DEVICE_CHANGE_ACTION_INCREASE": "increase to {count}",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_FREE": "Free",
|
||||
"DEVICE_CHANGE_ACTION_DECREASE": "decrease to {count}",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Confirm change</b>\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n",
|
||||
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
|
||||
"DEVICE_CHANGE_CHARGED": "💰 Charged: {amount}",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Payments are not refunded",
|
||||
"INVALID_AMOUNT": "❌ Invalid amount",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n",
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n",
|
||||
"MENU_ADMIN": "⚙️ Admin panel",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
|
||||
"MENU_INFO": "ℹ️ Info",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Info</b>",
|
||||
"MENU_INFO_PROMPT": "Choose a section:",
|
||||
"MENU_PROMO_GROUPS_INFO": "🎯 Promo groups",
|
||||
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Promo groups</b>",
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Spent in the bot: {amount}",
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Current level: {name}",
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Current level: not unlocked yet",
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 Until level “{name}”: spend {amount} more",
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 You already have the highest discount level!",
|
||||
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Auto-assigned levels:",
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — from {amount}",
|
||||
"PROMO_GROUPS_INFO_EMPTY": "Auto-assigned promo groups are not configured yet.",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
"MENU_SERVER_STATUS": "📊 Server status",
|
||||
"MENU_SUPPORT": "🛠️ Support",
|
||||
"OPERATION_CANCELLED": "❌ Operation cancelled",
|
||||
"PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
@@ -240,6 +352,21 @@
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Final configuration</b>\n\n📅 <b>Period:</b> {period} days\n📈 <b>Traffic:</b> {traffic}\n🌍 <b>Countries:</b> {countries}\n📱 <b>Devices:</b> {devices}\n\n💰 <b>Total:</b> {total_price}\n\nConfirm the purchase?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Trial subscription",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Technical support</b>\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n",
|
||||
"SERVER_STATUS_AVAILABLE": "✅ <b>Online</b>",
|
||||
"SERVER_STATUS_ERROR_SHORT": "Failed to fetch data",
|
||||
"SERVER_STATUS_LATENCY": "{latency} ms",
|
||||
"SERVER_STATUS_LATENCY_UNKNOWN": "no data",
|
||||
"SERVER_STATUS_NEXT_PAGE": "Next ➡️",
|
||||
"SERVER_STATUS_NO_SERVERS": "No server data available.",
|
||||
"SERVER_STATUS_NOT_CONFIGURED": "Feature is not available.",
|
||||
"SERVER_STATUS_OFFLINE": "no response",
|
||||
"SERVER_STATUS_PAGINATION": "Page {current} of {total}",
|
||||
"SERVER_STATUS_PREV_PAGE": "⬅️ Back",
|
||||
"SERVER_STATUS_REFRESH": "🔄 Refresh",
|
||||
"SERVER_STATUS_SUMMARY": "Total servers: {total} (online: {online}, offline: {offline})",
|
||||
"SERVER_STATUS_TITLE": "📊 <b>Server status</b>",
|
||||
"SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}",
|
||||
"SERVER_STATUS_UNAVAILABLE": "❌ <b>Offline</b>",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Confirm traffic change</b>\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Switch traffic limit</b>\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 <b>Important:</b>\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n",
|
||||
@@ -261,7 +388,12 @@
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_SERVER_RANDOM_POOL": "🎲 Random choice among {count} servers",
|
||||
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Trial server",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
|
||||
"USER_NOT_FOUND": "❌ User not found",
|
||||
"MENU_LANGUAGE": "🌐 Language",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
|
||||
@@ -294,7 +426,9 @@
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ The subscription link is available via the buttons below or in the “My subscription” section.",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\nℹ️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
@@ -376,6 +510,7 @@
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
@@ -386,5 +521,261 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
|
||||
"ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽",
|
||||
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
|
||||
"ADMIN_PROMO_OFFER_TEST_DURATION": "Access: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Server: {name}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified",
|
||||
"ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}",
|
||||
"ADMIN_PROMO_OFFER_SKIPPED": "Skipped: {skipped} (already have access)",
|
||||
"ADMIN_PROMO_OFFER_CTA_BUY": "Buy subscription",
|
||||
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Extend subscription",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ Back to offer",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ Back to promo offers",
|
||||
"ADMIN_REPORTS": "📊 Reports",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
|
||||
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Closed support tickets:",
|
||||
"ADMIN_TICKETS_TITLE_OPEN": "🎫 Open support tickets:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Enter support reply:",
|
||||
"ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!",
|
||||
"ATTACHMENTS_SENT": "✅ Attachments sent.",
|
||||
"BACK_TO_MENU": "🏠 Back to menu",
|
||||
"BACK_TO_OPEN_TICKETS": "🔴 Open tickets",
|
||||
"BACK_TO_SUPPORT": "⬅️ Back to support",
|
||||
"BACK_TO_TICKETS": "⬅️ Back to tickets",
|
||||
"BALANCE_TOPUP": "💳 Top up balance",
|
||||
"BLOCK_BY_TIME": "⏳ Temporary block",
|
||||
"BLOCK_FOREVER": "🚫 Block permanently",
|
||||
"CAMPAIGN_EXISTING_USERL": "ℹ️ This promotional link is available to new users only.",
|
||||
"CANCEL_REPLY": "❌ Cancel reply",
|
||||
"CANCEL_TICKET_CREATION": "❌ Cancel ticket creation",
|
||||
"CLOSED_TICKETS": "🟢 Closed",
|
||||
"CLOSED_TICKETS_HEADER": "🟢 Closed tickets",
|
||||
"CLOSED_TICKETS_TITLE": "🟢 Closed tickets:",
|
||||
"CLOSE_NOTIFICATION": "❌ Close notification",
|
||||
"CLOSE_TICKET": "🔒 Close ticket",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Contact support",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
|
||||
"DELETE_MESSAGE": "🗑 Delete",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
|
||||
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
|
||||
"PROMO_OFFER_CLOSE": "❌ Close",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically to your next payment.",
|
||||
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ This server is already connected to your subscription.",
|
||||
"ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):",
|
||||
"LANGUAGE_SELECTION_DISABLED": "⚙️ Language selection is temporarily unavailable. Using the default language.",
|
||||
"MARK_AS_ANSWERED": "✅ Mark as answered",
|
||||
"MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.",
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Mulen Pay payment</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay",
|
||||
"MULENPAY_TOPUP_PROMPT": "💳 <b>Mulen Pay payment</b>\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.",
|
||||
"MY_TICKETS_BUTTON": "📋 My tickets",
|
||||
"MY_TICKETS_TITLE": "📋 Your tickets:",
|
||||
"NOTIFICATION_CLOSED": "Notification closed.",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):",
|
||||
"NO_ATTACHMENTS": "No attachments.",
|
||||
"NO_CLOSED_TICKETS": "There are no closed tickets yet.",
|
||||
"NO_TICKETS": "You don't have any tickets yet.",
|
||||
"NO_TICKETS_ADMIN": "No tickets to display.",
|
||||
"OPEN_TICKETS": "🔴 Open",
|
||||
"OPEN_TICKETS_HEADER": "🔴 Open tickets",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
|
||||
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
|
||||
"PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
|
||||
"REPLY_TO_TICKET": "💬 Reply",
|
||||
"REPORT_CLOSE": "❌ Close",
|
||||
"REPORT_CLOSED": "✅ Report closed.",
|
||||
"REPORT_CLOSE_ERROR": "❌ Failed to close the report.",
|
||||
"SENDING_ATTACHMENTS": "📎 Sending attachments...",
|
||||
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nActivate the offer to get an additional discount. It stacks with your promo group and is valid until {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.",
|
||||
"SUBSCRIPTION_EXTEND": "💎 Extend subscription",
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",
|
||||
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.",
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually:",
|
||||
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Open link in Happ</a>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Connect via Happ</b>",
|
||||
"SUPPORT_BUTTON": "🆘 Support",
|
||||
"TICKET_ALREADY_OPEN": "You already have an open ticket. Please close it first.",
|
||||
"TICKET_ATTACHMENTS": "📎 Attachments",
|
||||
"TICKET_CLOSED": "✅ Ticket closed.",
|
||||
"TICKET_CLOSED_NO_REPLY": "❌ The ticket is closed; replying is not possible.",
|
||||
"TICKET_CLOSE_ERROR": "❌ Error closing ticket.",
|
||||
"TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.",
|
||||
"TICKET_CREATE_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
|
||||
"TICKET_CREATION_CANCELLED": "Ticket creation cancelled.",
|
||||
"TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
|
||||
"TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.",
|
||||
"TICKET_MESSAGE_INPUT": "Now describe your problem or question:",
|
||||
"TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:",
|
||||
"TICKET_NOT_FOUND": "Ticket not found.",
|
||||
"TICKET_PRIORITY_HIGH": "🟠 High",
|
||||
"TICKET_PRIORITY_LOW": "🟢 Low",
|
||||
"TICKET_PRIORITY_NORMAL": "🟡 Normal",
|
||||
"TICKET_PRIORITY_SELECT": "Select ticket priority:",
|
||||
"TICKET_PRIORITY_URGENT": "🔴 Urgent",
|
||||
"TICKET_REPLY_CANCELLED": "Reply cancelled.",
|
||||
"TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.",
|
||||
"TICKET_REPLY_INPUT": "Enter your reply:",
|
||||
"TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:",
|
||||
"TICKET_REPLY_SENT": "✅ Your reply has been sent!",
|
||||
"TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:",
|
||||
"TICKET_STATUS_ANSWERED": "Answered",
|
||||
"TICKET_STATUS_CLOSED": "Closed",
|
||||
"TICKET_STATUS_OPEN": "Open",
|
||||
"TICKET_STATUS_PENDING": "Pending",
|
||||
"TICKET_TITLE_INPUT": "Enter ticket title:",
|
||||
"TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:",
|
||||
"TICKET_UPDATE_ERROR": "❌ Error updating ticket.",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
|
||||
"UNBLOCK": "✅ Unblock",
|
||||
"USER_BLOCKED_FOREVER": "You are blocked from contacting support.",
|
||||
"USER_BLOCKED_UNTIL": "You are blocked until {time}",
|
||||
"VIEW_CLOSED_TICKETS": "🟢 Closed tickets",
|
||||
"VIEW_TICKET": "👁️ View ticket",
|
||||
"ADMIN_USERS_SUBMENU_TITLE": "👥 **User and subscription management**\n\n",
|
||||
"ADMIN_PROMO_SUBMENU_TITLE": "💰 **Promo codes and statistics**\n\n",
|
||||
"ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Communications**\n\n",
|
||||
"ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Manage broadcasts and interface texts:",
|
||||
"ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:",
|
||||
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Support**\n\n",
|
||||
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Manage tickets and support settings:",
|
||||
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Ticket access only.",
|
||||
"ADMIN_SUPPORT_MODERATION_TITLE": "🧑⚖️ <b>Support moderation</b>",
|
||||
"ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Access to support tickets.",
|
||||
"ADMIN_SUPPORT_AUDIT_TITLE": "🧾 <b>Moderator audit</b>",
|
||||
"ADMIN_SUPPORT_AUDIT_EMPTY": "Nothing here yet",
|
||||
"ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Moderator",
|
||||
"ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Admin",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Ticket closed",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Timed block",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Permanent block",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
|
||||
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Enabled",
|
||||
"ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Disabled",
|
||||
"ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "\"Support\" menu item",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Tickets",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Contact",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Both",
|
||||
"ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Edit description",
|
||||
"ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Admin notifications",
|
||||
"ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "User notifications",
|
||||
"ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
|
||||
"ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ SLA time: {minutes} min",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑⚖️ Moderators: {count}",
|
||||
"ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Assign moderator",
|
||||
"ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Remove moderator",
|
||||
"ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 <b>Support settings</b>",
|
||||
"ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Working hours and menu visibility. Current support menu description:",
|
||||
"ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ <b>SLA configuration</b>\n\nEnter the response wait time in minutes (integer > 0):",
|
||||
"ADMIN_SUPPORT_SLA_INVALID": "❌ Enter a valid number of minutes (1-1440)",
|
||||
"ADMIN_SUPPORT_SLA_SAVED": "✅ SLA value saved",
|
||||
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑⚖️ <b>Assign moderator</b>\n\nSend the user's Telegram ID (number)",
|
||||
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑⚖️ <b>Remove moderator</b>\n\nSend the user's Telegram ID (number)",
|
||||
"ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Enter a valid Telegram ID (number)",
|
||||
"ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Moderator {tid} removed",
|
||||
"ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Failed to remove moderator",
|
||||
"ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ User {tid} assigned as moderator",
|
||||
"ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Failed to assign moderator",
|
||||
"ADMIN_SUPPORT_MODERATORS_EMPTY": "List is empty",
|
||||
"ADMIN_SUPPORT_MODERATORS_TITLE": "🧑⚖️ <b>Moderators</b>",
|
||||
"ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Send description",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 <b>Editing support description</b>",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Current description:",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "<b>Contact for \"Contact\" mode</b>",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below",
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted",
|
||||
"MENU_PUBLIC_OFFER": "📄 Offer",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Public Offer</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
|
||||
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Public offer</b>",
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Text preview:</b>",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
|
||||
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Current offer text</b>",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled.",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Package visibility",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Package enabled.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Package disabled.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "At least one package must remain.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Traffic package visibility",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Active packages: {items}",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "No active packages.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Tap a package to toggle its visibility."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
{
|
||||
"ACCESS_DENIED": "❌ Доступ запрещен",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Управление странами подписки</b>\n\n📋 <b>Текущие страны ({current_count}):</b>\n{current_list}\n\n💡 <b>Инструкция:</b>\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!",
|
||||
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
|
||||
"PAID_FEATURE_ONLY": "⚠ Эта функция доступна только для платных подписок",
|
||||
"PAID_FEATURE_ONLY_SHORT": "⚠ Только для платных подписок",
|
||||
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ Сервер недоступен для вашей промогруппы",
|
||||
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ Изменения не обнаружены",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
|
||||
"COUNTRY_CHANGES_ADDED_HEADER": "➕ <b>Добавлены страны:</b>\n",
|
||||
"COUNTRY_CHANGES_CHARGED": "💰 Списано: {amount} (за {months} мес)",
|
||||
"COUNTRY_CHANGES_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Отключены страны:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным",
|
||||
"COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 <b>Активных стран:</b> {count}",
|
||||
"ADMIN_MAIN_MENU": "🏠 Главное меню",
|
||||
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
@@ -14,7 +28,16 @@
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Скидки промогруппы:",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Серверы: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Трафик: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Устройства: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Скидки на доп. услуги: <b>включены</b>",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Скидки на доп. услуги: <b>отключены</b>",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Скидки на докупку доп. услуг <b>включены</b>.",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Скидки на докупку доп. услуг <b>отключены</b>.",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
@@ -29,6 +52,22 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
|
||||
"ADMIN_USER_MANAGEMENT_PROFILE": "👤 <b>Управление пользователем</b>\n\n<b>Основная информация:</b>\n• Имя: {name}\n• ID: <code>{telegram_id}</code>\n• Username: {username}\n• Статус: {status}\n• Язык: {language}\n\n<b>Финансы:</b>\n• Баланс: {balance}\n• Транзакций: {transactions}\n\n<b>Активность:</b>\n• Регистрация: {registration}\n• Последняя активность: {last_activity}\n• Дней с регистрации: {registration_days}",
|
||||
"ADMIN_USER_USERNAME_NOT_SET": "не указан",
|
||||
"ADMIN_USER_STATUS_ACTIVE": "✅ Активен",
|
||||
"ADMIN_USER_STATUS_BLOCKED": "🚫 Заблокирован",
|
||||
"ADMIN_USER_STATUS_DELETED": "🗑️ Удален",
|
||||
"ADMIN_USER_STATUS_UNKNOWN": "❓ Неизвестно",
|
||||
"ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Неизвестно",
|
||||
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Триал",
|
||||
"ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Платная",
|
||||
"ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Активна",
|
||||
"ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Неактивна",
|
||||
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} ГБ",
|
||||
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "<b>Подписка:</b>\n• Тип: {type}\n• Статус: {status}\n• До: {end_date}\n• Трафик: {traffic}\n• Устройства: {devices}\n• Стран: {countries}",
|
||||
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Подписка:</b> Отсутствует",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Промогруппа:</b>\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Промогруппа:</b> Не назначена",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Промогруппа:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
|
||||
@@ -55,12 +94,20 @@
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_USERS": "👥 Пользователи",
|
||||
"ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:",
|
||||
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:",
|
||||
"AUTOPAY_BUTTON": "💳 Автоплатёж",
|
||||
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
|
||||
"AUTOPAY_STATUS_ENABLED": "включен",
|
||||
"AUTOPAY_STATUS_DISABLED": "выключен",
|
||||
"AUTOPAY_MENU_TEXT": "💳 <b>Автоплатеж</b>\n\n📊 <b>Статус:</b> {status}\n⏰ <b>Списание за:</b> {days} дн. до окончания\n\nВыберите действие:",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
|
||||
"AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Выберите за сколько дней до окончания списывать средства:",
|
||||
"AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
|
||||
"BALANCE_BUTTON": "💰 Баланс: {balance}",
|
||||
@@ -82,6 +129,7 @@
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CANCEL": "❌ Отмена",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
|
||||
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
|
||||
@@ -92,13 +140,25 @@
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
|
||||
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
|
||||
"CONFIRM": "✅ Подтвердить",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
|
||||
"CONNECT_BUTTON": "🔗 Подключиться",
|
||||
"HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ",
|
||||
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
|
||||
"HAPP_PLATFORM_IOS": "🍎 iOS",
|
||||
"HAPP_PLATFORM_ANDROID": "🤖 Android",
|
||||
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
|
||||
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
|
||||
"HAPP_PLATFORM_PC": "💻 ПК",
|
||||
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:",
|
||||
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена",
|
||||
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
|
||||
"CONTACT_SUPPORT": "💬 Написать в поддержку",
|
||||
"CONTINUE": "➡️ Продолжить",
|
||||
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram не позволяет боту запросить контакт из-за ваших настроек приватности.</b>\n\nРазрешите отправку контакта в настройках Telegram или поделитесь данными вручную в ответ на сообщение.",
|
||||
"CONTINUE_BUTTON": "✅ Продолжить",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
|
||||
"CREATE_INVITE": "📝 Создать приглашение",
|
||||
@@ -107,6 +167,19 @@
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
|
||||
"DEVICE_CHANGE_ACTION_INCREASE": "увеличить до {count}",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_FREE": "Бесплатно",
|
||||
"DEVICE_CHANGE_ACTION_DECREASE": "уменьшить до {count}",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Подтверждение изменения</b>\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n",
|
||||
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
|
||||
"DEVICE_CHANGE_CHARGED": "💰 Списано: {amount}",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
@@ -121,23 +194,71 @@
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_PROMPT": "🌐 Выберите язык интерфейса:",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {servers}",
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n<code>{bar}</code>",
|
||||
"MAIN_MENU_BUTTON": "🏠 Главное меню",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
|
||||
"DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден",
|
||||
"DEVICE_NONE_CONNECTED": "ℹ️ У вас нет подключенных устройств",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
|
||||
"DEVICE_MANAGEMENT_OVERVIEW": "🔄 <b>Управление устройствами</b>\n\n📊 Всего подключено: {total} устройств\n📄 Страница {page} из {pages}\n\n",
|
||||
"DEVICE_MANAGEMENT_CONNECTED_HEADER": "<b>Подключенные устройства:</b>\n",
|
||||
"DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
|
||||
"DEVICE_MANAGEMENT_ACTIONS": "\n💡 <b>Действия:</b>\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу",
|
||||
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
|
||||
"DEVICE_PAGE_LOAD_ERROR": "❌ Ошибка загрузки страницы",
|
||||
"DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос",
|
||||
"DEVICE_RESET_PARSE_ERROR": "❌ Ошибка обработки запроса",
|
||||
"DEVICE_RESET_SUCCESS": "✅ Устройство {device} успешно сброшено!",
|
||||
"DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены",
|
||||
"DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства",
|
||||
"DEVICE_RESET_NOT_FOUND": "❌ Устройство не найдено",
|
||||
"DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства",
|
||||
"DEVICE_LIST_FETCH_ERROR": "❌ Ошибка получения списка устройств",
|
||||
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>Все устройства успешно сброшены!</b>\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения",
|
||||
"DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ <b>Частичный сброс устройств</b>\n\n✅ Удалено: {success} устройств\n❌ Не удалось удалить: {failed} устройств\n\nПопробуйте еще раз или обратитесь в поддержку.",
|
||||
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Не удалось сбросить устройства</b>\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}",
|
||||
"MENU_ADMIN": "⚙️ Админ-панель",
|
||||
"MENU_BALANCE": "💰 Баланс",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
|
||||
"MENU_INFO": "ℹ️ Инфо",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Инфо</b>",
|
||||
"MENU_INFO_PROMPT": "Выберите раздел:",
|
||||
"MENU_PROMO_GROUPS_INFO": "🎯 Скидки за траты",
|
||||
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Скидки за траты</b>",
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Потрачено в боте: {amount}",
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Текущий уровень: {name}",
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Текущий уровень: пока не получен",
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 До уровня «{name}»: осталось {amount}",
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 Вы уже получили максимальный уровень скидок!",
|
||||
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Уровни с автовыдачей:",
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — от {amount}",
|
||||
"PROMO_GROUPS_INFO_EMPTY": "Промогруппы с автовыдачей ещё не настроены.",
|
||||
"MENU_PRIVACY_POLICY": "🛡️ Политика конф.",
|
||||
"MENU_PUBLIC_OFFER": "📄 Оферта",
|
||||
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
|
||||
"PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.",
|
||||
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
"MENU_RULES": "📋 Правила сервиса",
|
||||
"MENU_SERVER_STATUS": "📊 Статус серверов",
|
||||
"MENU_SUBSCRIPTION": "📱 Подписка",
|
||||
"MENU_SUPPORT": "🛠️ Техподдержка",
|
||||
"MENU_TRIAL": "🧪 Тестовая подписка",
|
||||
@@ -210,6 +331,9 @@
|
||||
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Настройки подписки</b>\n\n📊 <b>Текущие параметры:</b>\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
|
||||
"SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ У вас нет активной подписки!",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
|
||||
@@ -225,6 +349,21 @@
|
||||
"SUCCESS": "✅ Успешно",
|
||||
"REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Техническая поддержка</b>\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n",
|
||||
"SERVER_STATUS_AVAILABLE": "✅ <b>Доступны</b>",
|
||||
"SERVER_STATUS_ERROR_SHORT": "Не удалось получить данные",
|
||||
"SERVER_STATUS_LATENCY": "{latency} мс",
|
||||
"SERVER_STATUS_LATENCY_UNKNOWN": "нет данных",
|
||||
"SERVER_STATUS_NEXT_PAGE": "Вперед ➡️",
|
||||
"SERVER_STATUS_NO_SERVERS": "Нет данных о серверах.",
|
||||
"SERVER_STATUS_NOT_CONFIGURED": "Функция недоступна.",
|
||||
"SERVER_STATUS_OFFLINE": "нет ответа",
|
||||
"SERVER_STATUS_PAGINATION": "Страница {current} из {total}",
|
||||
"SERVER_STATUS_PREV_PAGE": "⬅️ Назад",
|
||||
"SERVER_STATUS_REFRESH": "🔄 Обновить",
|
||||
"SERVER_STATUS_SUMMARY": "Всего серверов: {total} (в сети: {online}, вне сети: {offline})",
|
||||
"SERVER_STATUS_TITLE": "📊 <b>Статус серверов</b>",
|
||||
"SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}",
|
||||
"SERVER_STATUS_UNAVAILABLE": "❌ <b>Недоступны</b>",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Подтверждение переключения трафика</b>\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Переключение лимита трафика</b>\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n",
|
||||
@@ -258,6 +397,9 @@
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Добавить трафик к подписке</b>\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
"USER_NOT_FOUND": "❌ Пользователь не найден",
|
||||
@@ -294,7 +436,9 @@
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
@@ -376,6 +520,7 @@
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
@@ -386,5 +531,285 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||
"ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽",
|
||||
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
|
||||
"ADMIN_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Сервер: {name}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны",
|
||||
"ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",
|
||||
"ADMIN_PROMO_OFFER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
|
||||
"ADMIN_PROMO_OFFER_CTA_BUY": "Купить подписку",
|
||||
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Продлить подписку",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ К предложению",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ К промопредложениям",
|
||||
"ADMIN_REPORTS": "📊 Отчеты",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
|
||||
"ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!",
|
||||
"ATTACHMENTS_SENT": "✅ Вложения отправлены.",
|
||||
"BACK_TO_MENU": "🏠 В главное меню",
|
||||
"BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты",
|
||||
"BACK_TO_SUPPORT": "⬅️ К поддержке",
|
||||
"BACK_TO_TICKETS": "⬅️ К тикетам",
|
||||
"BALANCE_TOPUP": "💳 Пополнить баланс",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
"BLOCK_FOREVER": "🚫 Заблокировать",
|
||||
"CAMPAIGN_EXISTING_USERL": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
|
||||
"CANCEL_REPLY": "❌ Отменить ответ",
|
||||
"CANCEL_TICKET_CREATION": "❌ Отменить создание тикета",
|
||||
"CLOSED_TICKETS": "🟢 Закрытые",
|
||||
"CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты",
|
||||
"CLOSED_TICKETS_TITLE": "🟢 Закрытые тикеты:",
|
||||
"CLOSE_NOTIFICATION": "❌ Закрыть уведомление",
|
||||
"CLOSE_TICKET": "🔒 Закрыть тикет",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
|
||||
"DELETE_MESSAGE": "🗑 Удалить",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
|
||||
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
|
||||
"PROMO_OFFER_CLOSE": "❌ Закрыть",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 <b>Скидка {percent}% активирована!</b> \n\nОна суммируется с другими скидками и автоматически применится при следующей оплате.",
|
||||
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ Этот сервер уже подключен к вашей подписке.",
|
||||
"ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
|
||||
"LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
|
||||
"MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Mulen Pay</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay",
|
||||
"MULENPAY_TOPUP_PROMPT": "💳 <b>Оплата через Mulen Pay</b>\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.",
|
||||
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
|
||||
"MY_TICKETS_TITLE": "📋 Ваши тикеты:",
|
||||
"NOTIFICATION_CLOSED": "Уведомление закрыто.",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):",
|
||||
"NO_ATTACHMENTS": "Вложений нет.",
|
||||
"NO_CLOSED_TICKETS": "Закрытых тикетов пока нет.",
|
||||
"NO_TICKETS": "У вас пока нет тикетов.",
|
||||
"NO_TICKETS_ADMIN": "Нет тикетов для отображения.",
|
||||
"OPEN_TICKETS": "🔴 Открытые",
|
||||
"OPEN_TICKETS_HEADER": "🔴 Открытые тикеты",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
||||
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
|
||||
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
|
||||
"REPLY_TO_TICKET": "💬 Ответить",
|
||||
"REPORT_CLOSE": "❌ Закрыть",
|
||||
"REPORT_CLOSED": "✅ Отчет закрыт.",
|
||||
"REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.",
|
||||
"SENDING_ATTACHMENTS": "📎 Отправляю вложения...",
|
||||
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
|
||||
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
|
||||
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
|
||||
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Подключение через Happ</b>",
|
||||
"SUPPORT_BUTTON": "🆘 Поддержка",
|
||||
"TICKET_ALREADY_OPEN": "У вас уже есть незакрытый тикет. Сначала закройте его.",
|
||||
"TICKET_ATTACHMENTS": "📎 Вложения",
|
||||
"TICKET_CLOSED": "✅ Тикет закрыт.",
|
||||
"TICKET_CLOSED_NO_REPLY": "❌ Тикет закрыт, ответить невозможно.",
|
||||
"TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.",
|
||||
"TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.",
|
||||
"TICKET_CREATE_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
|
||||
"TICKET_CREATION_CANCELLED": "Создание тикета отменено.",
|
||||
"TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
|
||||
"TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.",
|
||||
"TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:",
|
||||
"TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:",
|
||||
"TICKET_NOT_FOUND": "Тикет не найден.",
|
||||
"TICKET_PRIORITY_HIGH": "🟠 Высокий",
|
||||
"TICKET_PRIORITY_LOW": "🟢 Низкий",
|
||||
"TICKET_PRIORITY_NORMAL": "🟡 Обычный",
|
||||
"TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:",
|
||||
"TICKET_PRIORITY_URGENT": "🔴 Срочный",
|
||||
"TICKET_REPLY_CANCELLED": "Ответ отменен.",
|
||||
"TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.",
|
||||
"TICKET_REPLY_INPUT": "Введите ваш ответ:",
|
||||
"TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:",
|
||||
"TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!",
|
||||
"TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_STATUS_ANSWERED": "Отвечен",
|
||||
"TICKET_STATUS_CLOSED": "Закрыт",
|
||||
"TICKET_STATUS_OPEN": "Открыт",
|
||||
"TICKET_STATUS_PENDING": "В ожидании",
|
||||
"TICKET_TITLE_INPUT": "Введите заголовок тикета:",
|
||||
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
|
||||
"UNBLOCK": "✅ Разблокировать",
|
||||
"USER_BLOCKED_FOREVER": "Вы заблокированы для обращений в поддержку.",
|
||||
"USER_BLOCKED_UNTIL": "Вы заблокированы до {time}",
|
||||
"VIEW_CLOSED_TICKETS": "🟢 Закрытые тикеты",
|
||||
"VIEW_TICKET": "👁️ Посмотреть тикет",
|
||||
"ADMIN_USERS_SUBMENU_TITLE": "👥 **Управление пользователями и подписками**\n\n",
|
||||
"ADMIN_PROMO_SUBMENU_TITLE": "💰 **Промокоды и статистика**\n\n",
|
||||
"ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Коммуникации**\n\n",
|
||||
"ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Управление рассылками и текстами интерфейса:",
|
||||
"ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:",
|
||||
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
|
||||
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Управление тикетами и настройками поддержки:",
|
||||
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Доступ к тикетам.",
|
||||
"ADMIN_SUPPORT_MODERATION_TITLE": "🧑⚖️ <b>Модерация поддержки</b>",
|
||||
"ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Доступ к тикетам поддержки.",
|
||||
"ADMIN_SUPPORT_AUDIT_TITLE": "🧾 <b>Аудит модераторов</b>",
|
||||
"ADMIN_SUPPORT_AUDIT_EMPTY": "Пока пусто",
|
||||
"ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Модератор",
|
||||
"ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Админ",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Закрытие тикета",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Блокировка (время)",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Блокировка (навсегда)",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.",
|
||||
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "Политика конфиденциальности отображается в разделе «Инфо».",
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Показ политики выключен или текст отсутствует.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Политика активна и показывается пользователям.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Последнее обновление: {timestamp}",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Превью текста:</b>",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Изменить текст",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 Просмотреть текущий текст",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Текущий текст (превью):",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Используйте /html_help для справки по тегам.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Редактирование политики",
|
||||
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ Текст политики слишком длинный. Максимум 4000 символов.",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Политика конфиденциальности обновлена.",
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ К настройкам политики",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "Текст политики пока не задан.",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Текущий текст политики</b>",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена",
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
|
||||
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Превью текста:</b>",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
|
||||
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Текущий текст оферты</b>",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено.",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
|
||||
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
|
||||
"ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Отключены",
|
||||
"ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "Пункт «Техподдержка» в меню",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Тикеты",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Контакт",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Оба",
|
||||
"ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Изменить описание",
|
||||
"ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Админ-уведомления",
|
||||
"ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "Пользовательские уведомления",
|
||||
"ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
|
||||
"ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ Время SLA: {minutes} мин",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑⚖️ Модераторы: {count}",
|
||||
"ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Назначить модератора",
|
||||
"ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Удалить модератора",
|
||||
"ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 <b>Настройки поддержки</b>",
|
||||
"ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:",
|
||||
"ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
|
||||
"ADMIN_SUPPORT_SLA_INVALID": "❌ Введите корректное число минут (1-1440)",
|
||||
"ADMIN_SUPPORT_SLA_SAVED": "✅ Значение SLA сохранено",
|
||||
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
|
||||
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
|
||||
"ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Введите корректный Telegram ID (число)",
|
||||
"ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Модератор {tid} удалён",
|
||||
"ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Не удалось удалить модератора",
|
||||
"ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ Пользователь {tid} назначен модератором",
|
||||
"ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Не удалось назначить модератора",
|
||||
"ADMIN_SUPPORT_MODERATORS_EMPTY": "Список пуст",
|
||||
"ADMIN_SUPPORT_MODERATORS_TITLE": "🧑⚖️ <b>Модераторы</b>",
|
||||
"ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Прислать текст",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 <b>Редактирование описания поддержки</b>",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Текущее описание:",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "<b>Контакт для режима «Контакт»</b>",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже",
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Отображение пакетов",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Пакет включен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Пакет отключен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "Должен оставаться хотя бы один пакет.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Отображение пакетов трафика",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Активные пакеты: {items}",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "Активных пакетов нет.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Нажмите на пакет, чтобы включить или выключить его отображение."
|
||||
}
|
||||
|
||||
+12
-18
@@ -44,15 +44,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"SUPPORT_INFO": (
|
||||
"\n🛠️ <b>Техническая поддержка</b>\n\n"
|
||||
"По всем вопросам обращайтесь к нашей поддержке:\n\n"
|
||||
f"👤 {settings.SUPPORT_USERNAME}\n\n"
|
||||
"Мы поможем с:\n"
|
||||
"• Настройкой подключения\n"
|
||||
"• Решением технических проблем \n"
|
||||
"• Вопросами по оплате\n"
|
||||
"• Другими вопросами\n\n"
|
||||
"⏰ Время ответа: обычно в течение 1-2 часов\n"
|
||||
"\n🛟 <b>Поддержка</b>\n\n"
|
||||
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
|
||||
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
|
||||
"• 📋 Мои тикеты — статус и переписка\n"
|
||||
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
|
||||
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -72,15 +69,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"SUPPORT_INFO": (
|
||||
"\n🛠️ <b>Technical support</b>\n\n"
|
||||
"For any questions contact our support:\n\n"
|
||||
f"👤 {settings.SUPPORT_USERNAME}\n\n"
|
||||
"We can help with:\n"
|
||||
"• Connection setup\n"
|
||||
"• Troubleshooting issues\n"
|
||||
"• Payment questions\n"
|
||||
"• Other requests\n\n"
|
||||
"⏰ Response time: usually within 1-2 hours\n"
|
||||
"\n🛟 <b>RemnaWave Support</b>\n\n"
|
||||
"This is the ticket center: create requests, view replies and history.\n\n"
|
||||
"• 🎫 Create ticket — describe your issue or question\n"
|
||||
"• 📋 My tickets — status and conversation\n"
|
||||
"• 💬 Contact — message directly if needed\n\n"
|
||||
"Prefer tickets — it helps us respond faster and keep context.\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
+13
-6
@@ -12,6 +12,7 @@ from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.states import RegistrationStates
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +24,7 @@ async def _refresh_remnawave_description(
|
||||
) -> None:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
await api.update_user(uuid=remnawave_uuid, description=description)
|
||||
logger.info(
|
||||
f"✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave"
|
||||
@@ -115,6 +116,7 @@ class AuthMiddleware(BaseMiddleware):
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_language.state,
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
@@ -125,7 +127,10 @@ class AuthMiddleware(BaseMiddleware):
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
and (
|
||||
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
|
||||
or event.data.startswith('language_select:')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -158,15 +163,17 @@ class AuthMiddleware(BaseMiddleware):
|
||||
logger.info(f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.first_name != user.first_name:
|
||||
safe_first = sanitize_telegram_name(user.first_name)
|
||||
safe_last = sanitize_telegram_name(user.last_name)
|
||||
if db_user.first_name != safe_first:
|
||||
old_first_name = db_user.first_name
|
||||
db_user.first_name = user.first_name
|
||||
db_user.first_name = safe_first
|
||||
logger.info(f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.last_name != user.last_name:
|
||||
if db_user.last_name != safe_last:
|
||||
old_last_name = db_user.last_name
|
||||
db_user.last_name = user.last_name
|
||||
db_user.last_name = safe_last
|
||||
logger.info(f"🔄 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
|
||||
@@ -7,10 +7,15 @@ from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,6 +54,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
# Админам разрешаем пропускать проверку подписки, чтобы не блокировать
|
||||
# работу панели управления даже при отсутствии подписки. Важно делать
|
||||
# это до обращения к состоянию, чтобы не выполнять лишние операции.
|
||||
if settings.is_admin(telegram_id):
|
||||
logger.debug(
|
||||
"✅ Пользователь %s является администратором — пропускаем проверку подписки",
|
||||
telegram_id,
|
||||
)
|
||||
return await handler(event, data)
|
||||
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
@@ -85,11 +100,14 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
elif member.status in self.BAD_MEMBER_STATUS:
|
||||
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
|
||||
|
||||
|
||||
if telegram_id:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
return
|
||||
|
||||
return
|
||||
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
else:
|
||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||
@@ -110,6 +128,53 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||
return await handler(event, data)
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
async for db in get_db():
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user or not user.subscription:
|
||||
logger.debug(
|
||||
"⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию",
|
||||
telegram_id,
|
||||
)
|
||||
break
|
||||
|
||||
subscription = user.subscription
|
||||
if (not subscription.is_trial or
|
||||
subscription.status != SubscriptionStatus.ACTIVE.value):
|
||||
logger.debug(
|
||||
"ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
|
||||
telegram_id,
|
||||
subscription.is_trial,
|
||||
subscription.status,
|
||||
)
|
||||
break
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info(
|
||||
"🚫 Триальная подписка пользователя %s отключена после отписки от канала",
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
if user.remnawave_uuid:
|
||||
service = SubscriptionService()
|
||||
try:
|
||||
await service.disable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
"❌ Не удалось отключить пользователя RemnaWave %s: %s",
|
||||
user.remnawave_uuid,
|
||||
api_error,
|
||||
)
|
||||
except Exception as db_error:
|
||||
logger.error(
|
||||
"❌ Ошибка деактивации подписки пользователя %s после отписки: %s",
|
||||
telegram_id,
|
||||
db_error,
|
||||
)
|
||||
finally:
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
|
||||
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
|
||||
|
||||
@@ -4,6 +4,7 @@ import time
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,13 +34,31 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
if now - last_call < self.rate_limit:
|
||||
logger.warning(f"🚫 Throttling для пользователя {user_id}")
|
||||
|
||||
|
||||
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
|
||||
if isinstance(event, Message):
|
||||
try:
|
||||
fsm: FSMContext = data.get("state") # может отсутствовать
|
||||
current = await fsm.get_state() if fsm else None
|
||||
except Exception:
|
||||
current = None
|
||||
is_ticket_state = False
|
||||
if current:
|
||||
# Молчим только в состояниях работы с тикетами (user/admin): waiting_for_message / waiting_for_reply
|
||||
lowered = str(current)
|
||||
is_ticket_state = (
|
||||
(":waiting_for_message" in lowered or ":waiting_for_reply" in lowered) and
|
||||
("TicketStates" in lowered or "AdminTicketStates" in lowered)
|
||||
)
|
||||
if is_ticket_state:
|
||||
return
|
||||
# В остальных случаях — явный блок
|
||||
await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!")
|
||||
return
|
||||
# Для callback допустим краткое уведомление
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True)
|
||||
|
||||
return
|
||||
return
|
||||
|
||||
self.user_buckets[user_id] = now
|
||||
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram import Bot, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, Subscription, Transaction
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
PromoCodeType,
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,25 +28,147 @@ class AdminNotificationService:
|
||||
self.bot = bot
|
||||
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
|
||||
try:
|
||||
referrer = await get_user_by_id(db, referred_by_id)
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
else:
|
||||
return f"ID {referrer.telegram_id}"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
|
||||
return f"ID {referred_by_id}"
|
||||
|
||||
async def _get_user_promo_group(self, db: AsyncSession, user: User) -> Optional[PromoGroup]:
|
||||
if getattr(user, "promo_group", None):
|
||||
return user.promo_group
|
||||
|
||||
if not user.promo_group_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
await db.refresh(user, attribute_names=["promo_group"])
|
||||
except Exception:
|
||||
# relationship might not be available — fallback to direct fetch
|
||||
pass
|
||||
|
||||
if getattr(user, "promo_group", None):
|
||||
return user.promo_group
|
||||
|
||||
try:
|
||||
return await get_promo_group_by_id(db, user.promo_group_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка загрузки промогруппы %s пользователя %s: %s",
|
||||
user.promo_group_id,
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
||||
discount_lines: List[str] = []
|
||||
|
||||
discount_map = {
|
||||
"servers": ("Серверы", promo_group.server_discount_percent),
|
||||
"traffic": ("Трафик", promo_group.traffic_discount_percent),
|
||||
"devices": ("Устройства", promo_group.device_discount_percent),
|
||||
}
|
||||
|
||||
for _, (title, percent) in discount_map.items():
|
||||
if percent and percent > 0:
|
||||
discount_lines.append(f"• {title}: -{percent}%")
|
||||
|
||||
period_discounts_raw = promo_group.period_discounts or {}
|
||||
period_items: List[tuple[int, int]] = []
|
||||
|
||||
if isinstance(period_discounts_raw, dict):
|
||||
for raw_days, raw_percent in period_discounts_raw.items():
|
||||
try:
|
||||
days = int(raw_days)
|
||||
percent = int(raw_percent)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if percent > 0:
|
||||
period_items.append((days, percent))
|
||||
|
||||
period_items.sort(key=lambda item: item[0])
|
||||
|
||||
if period_items:
|
||||
formatted_periods = ", ".join(
|
||||
f"{days} д. — -{percent}%" for days, percent in period_items
|
||||
)
|
||||
discount_lines.append(f"• Периоды: {formatted_periods}")
|
||||
|
||||
if promo_group.apply_discounts_to_addons:
|
||||
discount_lines.append("• Доп. услуги: ✅ скидка действует")
|
||||
else:
|
||||
discount_lines.append("• Доп. услуги: ❌ без скидки")
|
||||
|
||||
return discount_lines
|
||||
|
||||
def _format_promo_group_block(
|
||||
self,
|
||||
promo_group: Optional[PromoGroup],
|
||||
*,
|
||||
title: str = "Промогруппа",
|
||||
icon: str = "🏷️",
|
||||
) -> str:
|
||||
if not promo_group:
|
||||
return f"{icon} <b>{title}:</b> —"
|
||||
|
||||
lines = [f"{icon} <b>{title}:</b> {promo_group.name}"]
|
||||
|
||||
discount_lines = self._format_promo_group_discounts(promo_group)
|
||||
if discount_lines:
|
||||
lines.append("💸 <b>Скидки:</b>")
|
||||
lines.extend(discount_lines)
|
||||
else:
|
||||
lines.append("💸 <b>Скидки:</b> отсутствуют")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_promocode_type_display(self, promo_type: Optional[str]) -> str:
|
||||
mapping = {
|
||||
PromoCodeType.BALANCE.value: "💰 Бонус на баланс",
|
||||
PromoCodeType.SUBSCRIPTION_DAYS.value: "⏰ Доп. дни подписки",
|
||||
PromoCodeType.TRIAL_SUBSCRIPTION.value: "🎁 Триал подписка",
|
||||
}
|
||||
|
||||
if not promo_type:
|
||||
return "ℹ️ Не указан"
|
||||
|
||||
return mapping.get(promo_type, f"ℹ️ {promo_type}")
|
||||
|
||||
def _format_campaign_bonus(self, campaign: AdvertisingCampaign) -> List[str]:
|
||||
if campaign.is_balance_bonus:
|
||||
return [
|
||||
f"💰 Баланс: {settings.format_price(campaign.balance_bonus_kopeks or 0)}",
|
||||
]
|
||||
|
||||
if campaign.is_subscription_bonus:
|
||||
default_devices = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1)
|
||||
details = [
|
||||
f"📅 Дней подписки: {campaign.subscription_duration_days or 0}",
|
||||
f"📊 Трафик: {campaign.subscription_traffic_gb or 0} ГБ",
|
||||
f"📱 Устройства: {campaign.subscription_device_limit or default_devices}",
|
||||
]
|
||||
if campaign.subscription_squads:
|
||||
details.append(f"🌐 Сквады: {len(campaign.subscription_squads)} шт.")
|
||||
return details
|
||||
|
||||
return ["ℹ️ Бонусы не предусмотрены"]
|
||||
|
||||
async def send_trial_activation_notification(
|
||||
self,
|
||||
@@ -50,7 +182,9 @@ class AdminNotificationService:
|
||||
try:
|
||||
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -58,6 +192,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
@@ -100,7 +236,9 @@ class AdminNotificationService:
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -108,6 +246,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -212,13 +352,30 @@ class AdminNotificationService:
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение"
|
||||
deposit_count_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Transaction)
|
||||
.where(
|
||||
Transaction.user_id == user.id,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True)
|
||||
)
|
||||
)
|
||||
deposit_count = deposit_count_result.scalar_one() or 0
|
||||
topup_status = "🆕 Первое пополнение" if deposit_count <= 1 else "🔄 Пополнение"
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
subscription_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = subscription_result.scalar_one_or_none()
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -226,6 +383,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Детали пополнения:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -237,7 +396,7 @@ class AdminNotificationService:
|
||||
➕ Изменение: +{settings.format_price(balance_change)}
|
||||
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
@@ -254,21 +413,31 @@ class AdminNotificationService:
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
extended_days: int,
|
||||
old_end_date: datetime
|
||||
old_end_date: datetime,
|
||||
*,
|
||||
new_end_date: datetime | None = None,
|
||||
balance_after: int | None = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
current_end_date = new_end_date or subscription.end_date
|
||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -277,24 +446,207 @@ class AdminNotificationService:
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
return False
|
||||
|
||||
async def _send_message(self, text: str) -> bool:
|
||||
|
||||
async def send_promocode_activation_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
promocode_data: Dict[str, Any],
|
||||
effect_description: str,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
type_display = self._get_promocode_type_display(promocode_data.get("type"))
|
||||
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
|
||||
|
||||
message_lines = [
|
||||
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🎟️ <b>Промокод:</b>",
|
||||
f"🔖 Код: <code>{promocode_data.get('code')}</code>",
|
||||
f"🧾 Тип: {type_display}",
|
||||
f"📊 Использования: {usage_info}",
|
||||
]
|
||||
|
||||
balance_bonus = promocode_data.get("balance_bonus_kopeks", 0)
|
||||
if balance_bonus:
|
||||
message_lines.append(
|
||||
f"💰 Бонус на баланс: {settings.format_price(balance_bonus)}"
|
||||
)
|
||||
|
||||
subscription_days = promocode_data.get("subscription_days", 0)
|
||||
if subscription_days:
|
||||
message_lines.append(f"📅 Доп. дни подписки: {subscription_days}")
|
||||
|
||||
valid_until = promocode_data.get("valid_until")
|
||||
if valid_until:
|
||||
message_lines.append(
|
||||
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
|
||||
if isinstance(valid_until, datetime)
|
||||
else f"⏳ Действует до: {valid_until}"
|
||||
)
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
"📝 <b>Эффект:</b>",
|
||||
effect_description.strip() or "✅ Промокод активирован",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об активации промокода: {e}")
|
||||
return False
|
||||
|
||||
async def send_campaign_link_visit_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
telegram_user: types.User,
|
||||
campaign: AdvertisingCampaign,
|
||||
user: Optional[User] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
user_status = "🆕 Новый пользователь" if not user else "👥 Уже зарегистрирован"
|
||||
promo_block = (
|
||||
self._format_promo_group_block(await self._get_user_promo_group(db, user))
|
||||
if user
|
||||
else self._format_promo_group_block(None)
|
||||
)
|
||||
|
||||
full_name = telegram_user.full_name or telegram_user.username or str(telegram_user.id)
|
||||
username = f"@{telegram_user.username}" if telegram_user.username else "отсутствует"
|
||||
|
||||
message_lines = [
|
||||
"📣 <b>ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ</b>",
|
||||
"",
|
||||
f"🧾 <b>Кампания:</b> {campaign.name}",
|
||||
f"🆔 ID кампании: {campaign.id}",
|
||||
f"🔗 Start-параметр: <code>{campaign.start_parameter}</code>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{telegram_user.id}</code>",
|
||||
f"📱 <b>Username:</b> {username}",
|
||||
user_status,
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🎯 <b>Бонус кампании:</b>",
|
||||
]
|
||||
|
||||
bonus_lines = self._format_campaign_bonus(campaign)
|
||||
message_lines.extend(bonus_lines)
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о переходе по кампании: {e}")
|
||||
return False
|
||||
|
||||
async def send_user_promo_group_change_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
old_group: Optional[PromoGroup],
|
||||
new_group: PromoGroup,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
initiator: Optional[User] = None,
|
||||
automatic: bool = False,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
title = "🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ" if automatic else "👥 СМЕНА ПРОМОГРУППЫ"
|
||||
initiator_line = None
|
||||
if initiator:
|
||||
initiator_line = (
|
||||
f"👮 <b>Инициатор:</b> {initiator.full_name} (ID: {initiator.telegram_id})"
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = "🤖 Автоматическое назначение"
|
||||
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
|
||||
]
|
||||
|
||||
if old_group and old_group.id != new_group.id:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
self._format_promo_group_block(
|
||||
old_group, title="Предыдущая промогруппа", icon="♻️"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if initiator_line:
|
||||
message_lines.extend(["", initiator_line])
|
||||
|
||||
if reason:
|
||||
message_lines.extend(["", f"📝 Причина: {reason}"])
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о смене промогруппы: {e}")
|
||||
return False
|
||||
|
||||
async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
|
||||
return False
|
||||
@@ -307,8 +659,16 @@ class AdminNotificationService:
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
|
||||
if self.topic_id:
|
||||
message_kwargs['message_thread_id'] = self.topic_id
|
||||
# route to ticket-specific topic if provided
|
||||
thread_id = None
|
||||
if ticket_event and self.ticket_topic_id:
|
||||
thread_id = self.ticket_topic_id
|
||||
elif self.topic_id:
|
||||
thread_id = self.topic_id
|
||||
if thread_id:
|
||||
message_kwargs['message_thread_id'] = thread_id
|
||||
if reply_markup is not None:
|
||||
message_kwargs['reply_markup'] = reply_markup
|
||||
|
||||
await self.bot.send_message(**message_kwargs)
|
||||
logger.info(f"Уведомление отправлено в чат {self.chat_id}")
|
||||
@@ -332,6 +692,8 @@ class AdminNotificationService:
|
||||
'telegram_stars': '⭐ Telegram Stars',
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': '💳 Mulen Pay (карта)',
|
||||
'pal24': '🏦 PayPalych (СБП)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
}
|
||||
@@ -339,22 +701,21 @@ class AdminNotificationService:
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
|
||||
return method_names.get(payment_method, f'💰 С баланса')
|
||||
return method_names.get(payment_method, '💰 С баланса')
|
||||
|
||||
def _format_traffic(self, traffic_gb: int) -> str:
|
||||
if traffic_gb == 0:
|
||||
return "∞ Безлимит"
|
||||
return f"{traffic_gb} ГБ"
|
||||
|
||||
def _get_subscription_status(self, user: User) -> str:
|
||||
if not user.subscription:
|
||||
def _get_subscription_status(self, subscription: Optional[Subscription]) -> str:
|
||||
if not subscription:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
sub = user.subscription
|
||||
if sub.is_trial:
|
||||
return f"🎯 Триал (до {sub.end_date.strftime('%d.%m')})"
|
||||
elif sub.is_active:
|
||||
return f"✅ Активна (до {sub.end_date.strftime('%d.%m')})"
|
||||
|
||||
if subscription.is_trial:
|
||||
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
|
||||
elif subscription.is_active:
|
||||
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
@@ -387,40 +748,32 @@ class AdminNotificationService:
|
||||
if details.get("auto_enabled", False):
|
||||
icon = "⚠️"
|
||||
title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "warning"
|
||||
else:
|
||||
icon = "🔧"
|
||||
title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "info"
|
||||
|
||||
elif event_type == "disable":
|
||||
icon = "✅"
|
||||
title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "success"
|
||||
|
||||
elif event_type == "api_status":
|
||||
if status == "online":
|
||||
icon = "🟢"
|
||||
title = "API REMNAWAVE ВОССТАНОВЛЕНО"
|
||||
alert_type = "success"
|
||||
else:
|
||||
icon = "🔴"
|
||||
title = "API REMNAWAVE НЕДОСТУПНО"
|
||||
alert_type = "error"
|
||||
|
||||
elif event_type == "monitoring":
|
||||
if status == "started":
|
||||
icon = "🔍"
|
||||
title = "МОНИТОРИНГ ЗАПУЩЕН"
|
||||
alert_type = "info"
|
||||
else:
|
||||
icon = "⏹️"
|
||||
title = "МОНИТОРИНГ ОСТАНОВЛЕН"
|
||||
alert_type = "info"
|
||||
else:
|
||||
icon = "ℹ️"
|
||||
title = "СИСТЕМА ТЕХРАБОТ"
|
||||
alert_type = "info"
|
||||
|
||||
message_parts = [f"{icon} <b>{title}</b>", ""]
|
||||
|
||||
@@ -610,103 +963,6 @@ class AdminNotificationService:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_remnawave_panel_status_notification(
|
||||
self,
|
||||
status: str,
|
||||
details: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
details = details or {}
|
||||
|
||||
status_config = {
|
||||
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
|
||||
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
|
||||
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
|
||||
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
|
||||
}
|
||||
|
||||
config = status_config.get(status, status_config["offline"])
|
||||
|
||||
message_parts = [
|
||||
f"{config['icon']} <b>{config['title']}</b>",
|
||||
""
|
||||
]
|
||||
|
||||
if details.get("api_url"):
|
||||
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
|
||||
|
||||
if details.get("response_time"):
|
||||
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
|
||||
|
||||
if details.get("last_check"):
|
||||
last_check = details["last_check"]
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
|
||||
|
||||
if details.get("users_online"):
|
||||
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("✅ Все системы работают нормально.")
|
||||
|
||||
elif status == "offline":
|
||||
if details.get("error"):
|
||||
error_msg = str(details["error"])[:150]
|
||||
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
|
||||
|
||||
if details.get("consecutive_failures"):
|
||||
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
|
||||
|
||||
elif status == "degraded":
|
||||
if details.get("issues"):
|
||||
issues = details["issues"]
|
||||
if isinstance(issues, list):
|
||||
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
|
||||
for issue in issues[:3]:
|
||||
message_parts.append(f" • {issue}")
|
||||
else:
|
||||
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель работает, но возможны задержки или сбои.")
|
||||
|
||||
elif status == "maintenance":
|
||||
if details.get("maintenance_reason"):
|
||||
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
|
||||
|
||||
if details.get("estimated_duration"):
|
||||
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
|
||||
|
||||
if details.get("manual_message"):
|
||||
message_parts.append(f"💬 <b>Сообщение:</b> {details['manual_message']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_update_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -722,50 +978,64 @@ class AdminNotificationService:
|
||||
|
||||
try:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
|
||||
}
|
||||
|
||||
|
||||
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
|
||||
|
||||
message = f"""{title}
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
🔧 <b>Изменение:</b>
|
||||
📋 Параметр: {param_name}"""
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🔧 <b>Изменение:</b>",
|
||||
f"📋 Параметр: {param_name}",
|
||||
]
|
||||
|
||||
if update_type == "servers":
|
||||
old_servers_info = await self._format_servers_detailed(old_value)
|
||||
new_servers_info = await self._format_servers_detailed(new_value)
|
||||
|
||||
message += f"""
|
||||
📉 Было: {old_servers_info}
|
||||
📈 Стало: {new_servers_info}"""
|
||||
message_lines.extend(
|
||||
[
|
||||
f"📉 Было: {old_servers_info}",
|
||||
f"📈 Стало: {new_servers_info}",
|
||||
]
|
||||
)
|
||||
else:
|
||||
message += f"""
|
||||
📉 Было: {self._format_update_value(old_value, update_type)}
|
||||
📈 Стало: {self._format_update_value(new_value, update_type)}"""
|
||||
message_lines.extend(
|
||||
[
|
||||
f"📉 Было: {self._format_update_value(old_value, update_type)}",
|
||||
f"📈 Стало: {self._format_update_value(new_value, update_type)}",
|
||||
]
|
||||
)
|
||||
|
||||
if price_paid > 0:
|
||||
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
|
||||
message_lines.append(f"💰 Доплачено: {settings.format_price(price_paid)}")
|
||||
else:
|
||||
message += f"\n💸 Бесплатно"
|
||||
message_lines.append("💸 Бесплатно")
|
||||
|
||||
message += f"""
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
|
||||
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
|
||||
f"🔗 <b>Рефер:</b> {referrer_info}",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Рефер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
|
||||
@@ -801,3 +1071,21 @@ class AdminNotificationService:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
async def send_ticket_event_notification(
|
||||
self,
|
||||
text: str,
|
||||
keyboard: types.InlineKeyboardMarkup | None = None
|
||||
) -> bool:
|
||||
"""Публичный метод для отправки уведомлений по тикетам в админ-топик.
|
||||
Учитывает настройки включенности в settings.
|
||||
"""
|
||||
# Respect runtime toggle for admin ticket notifications
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
runtime_enabled = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
except Exception:
|
||||
runtime_enabled = True
|
||||
if not (self._is_enabled() and runtime_enabled):
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
|
||||
+295
-73
@@ -12,6 +12,7 @@ import aiofiles
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
@@ -21,7 +22,10 @@ from app.database.models import (
|
||||
ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog,
|
||||
SubscriptionConversion, SentNotification, BroadcastHistory,
|
||||
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
|
||||
CryptoBotPayment, WelcomeText, Base
|
||||
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
|
||||
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
|
||||
server_squad_promo_groups
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class BackupMetadata:
|
||||
timestamp: str
|
||||
version: str = "1.1"
|
||||
version: str = "1.2"
|
||||
database_type: str = "postgresql"
|
||||
backup_type: str = "full"
|
||||
tables_count: int = 0
|
||||
@@ -61,32 +65,43 @@ class BackupService:
|
||||
self._settings = self._load_settings()
|
||||
|
||||
self.backup_models_ordered = [
|
||||
ServiceRule,
|
||||
SystemSetting,
|
||||
ServiceRule,
|
||||
Squad,
|
||||
PromoCode,
|
||||
ServerSquad,
|
||||
|
||||
User,
|
||||
|
||||
WelcomeText,
|
||||
PromoGroup,
|
||||
User,
|
||||
PromoCode,
|
||||
WelcomeText,
|
||||
UserMessage,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionConversion,
|
||||
Transaction,
|
||||
YooKassaPayment,
|
||||
CryptoBotPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SubscriptionConversion,
|
||||
SentNotification,
|
||||
DiscountOffer,
|
||||
BroadcastHistory,
|
||||
UserMessage,
|
||||
|
||||
SentNotification,
|
||||
SubscriptionServer,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
SupportAuditLog,
|
||||
WebApiToken,
|
||||
]
|
||||
|
||||
|
||||
if self._settings.include_logs:
|
||||
self.backup_models_ordered.append(MonitoringLog)
|
||||
|
||||
self.association_tables = {
|
||||
"server_squad_promo_groups": server_squad_promo_groups,
|
||||
}
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
return BackupSettings(
|
||||
auto_backup_enabled=os.getenv("BACKUP_AUTO_ENABLED", "true").lower() == "true",
|
||||
@@ -164,6 +179,7 @@ class BackupService:
|
||||
models_to_backup.append(MonitoringLog)
|
||||
|
||||
backup_data = {}
|
||||
association_data = {}
|
||||
total_records = 0
|
||||
|
||||
async for db in get_db():
|
||||
@@ -207,7 +223,11 @@ class BackupService:
|
||||
total_records += len(table_data)
|
||||
|
||||
logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
|
||||
|
||||
|
||||
association_data = await self._export_association_tables(db)
|
||||
for records in association_data.values():
|
||||
total_records += len(records)
|
||||
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте данных: {e}")
|
||||
@@ -219,7 +239,7 @@ class BackupService:
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
database_type="postgresql" if settings.is_postgresql() else "sqlite",
|
||||
backup_type="full",
|
||||
tables_count=len(models_to_backup),
|
||||
tables_count=len(models_to_backup) + len(association_data),
|
||||
total_records=total_records,
|
||||
compressed=compress,
|
||||
created_by=created_by,
|
||||
@@ -232,10 +252,17 @@ class BackupService:
|
||||
filename += ".gz"
|
||||
|
||||
backup_path = self.backup_dir / filename
|
||||
|
||||
|
||||
file_snapshots = await self._collect_file_snapshots()
|
||||
|
||||
backup_structure = {
|
||||
"metadata": asdict(metadata),
|
||||
"data": backup_data
|
||||
"data": backup_data,
|
||||
"associations": association_data,
|
||||
"files": file_snapshots,
|
||||
"config": {
|
||||
"backup_settings": asdict(self._settings)
|
||||
}
|
||||
}
|
||||
|
||||
if compress:
|
||||
@@ -264,7 +291,7 @@ class BackupService:
|
||||
size_mb = file_size / 1024 / 1024
|
||||
message = (f"✅ Бекап успешно создан!\n"
|
||||
f"📁 Файл: {filename}\n"
|
||||
f"📊 Таблиц: {len(models_to_backup)}\n"
|
||||
f"📊 Таблиц: {metadata.tables_count}\n"
|
||||
f"📈 Записей: {total_records:,}\n"
|
||||
f"💾 Размер: {size_mb:.2f} MB")
|
||||
|
||||
@@ -312,6 +339,8 @@ class BackupService:
|
||||
|
||||
metadata = backup_structure.get("metadata", {})
|
||||
backup_data = backup_structure.get("data", {})
|
||||
association_data = backup_structure.get("associations", {})
|
||||
file_snapshots = backup_structure.get("files", {})
|
||||
|
||||
if not backup_data:
|
||||
return False, "❌ Файл бекапа не содержит данных"
|
||||
@@ -329,60 +358,55 @@ class BackupService:
|
||||
await self._clear_database_tables(db)
|
||||
|
||||
models_by_table = {model.__tablename__: model for model in self.backup_models_ordered}
|
||||
|
||||
await self._restore_users_without_referrals(db, backup_data, models_by_table)
|
||||
|
||||
for model in self.backup_models_ordered:
|
||||
table_name = model.__tablename__
|
||||
|
||||
if table_name == "users":
|
||||
|
||||
pre_restore_tables = {"promo_groups"}
|
||||
for table_name in pre_restore_tables:
|
||||
model = models_by_table.get(table_name)
|
||||
if not model:
|
||||
continue
|
||||
|
||||
|
||||
records = backup_data.get(table_name, [])
|
||||
if not records:
|
||||
continue
|
||||
|
||||
|
||||
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
|
||||
|
||||
for record_data in records:
|
||||
try:
|
||||
processed_data = self._process_record_data(record_data, model, table_name)
|
||||
|
||||
primary_key_col = self._get_primary_key_column(model)
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
existing_record = await db.execute(
|
||||
select(model).where(
|
||||
getattr(model, primary_key_col) == processed_data[primary_key_col]
|
||||
)
|
||||
)
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing and not clear_existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col:
|
||||
setattr(existing, key, value)
|
||||
logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}")
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
|
||||
restored_records += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
|
||||
logger.error(f"Проблемные данные: {record_data}")
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
restored = await self._restore_table_records(db, model, table_name, records, clear_existing)
|
||||
restored_records += restored
|
||||
|
||||
if restored:
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
await self._restore_users_without_referrals(db, backup_data, models_by_table)
|
||||
|
||||
for model in self.backup_models_ordered:
|
||||
table_name = model.__tablename__
|
||||
|
||||
if table_name == "users" or table_name in pre_restore_tables:
|
||||
continue
|
||||
|
||||
records = backup_data.get(table_name, [])
|
||||
if not records:
|
||||
continue
|
||||
|
||||
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
|
||||
restored = await self._restore_table_records(db, model, table_name, records, clear_existing)
|
||||
restored_records += restored
|
||||
|
||||
if restored:
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
await self._update_user_referrals(db, backup_data)
|
||||
|
||||
|
||||
assoc_tables, assoc_records = await self._restore_association_tables(
|
||||
db,
|
||||
association_data,
|
||||
clear_existing
|
||||
)
|
||||
restored_tables += assoc_tables
|
||||
restored_records += assoc_records
|
||||
|
||||
await db.commit()
|
||||
|
||||
break
|
||||
@@ -403,6 +427,11 @@ class BackupService:
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("restore_success", message)
|
||||
|
||||
if file_snapshots:
|
||||
restored_files = await self._restore_file_snapshots(file_snapshots)
|
||||
if restored_files:
|
||||
logger.info(f"📁 Восстановлено файлов конфигурации: {restored_files}")
|
||||
|
||||
return True, message
|
||||
|
||||
@@ -549,14 +578,156 @@ class BackupService:
|
||||
return col.name
|
||||
return None
|
||||
|
||||
async def _export_association_tables(self, db: AsyncSession) -> Dict[str, List[Dict[str, Any]]]:
|
||||
association_data: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
for table_name, table_obj in self.association_tables.items():
|
||||
try:
|
||||
logger.info(f"📊 Экспортируем таблицу связей: {table_name}")
|
||||
result = await db.execute(select(table_obj))
|
||||
rows = result.mappings().all()
|
||||
association_data[table_name] = [dict(row) for row in rows]
|
||||
logger.info(
|
||||
f"✅ Экспортировано {len(rows)} связей из {table_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка экспорта таблицы связей {table_name}: {e}")
|
||||
|
||||
return association_data
|
||||
|
||||
async def _restore_association_tables(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
association_data: Dict[str, List[Dict[str, Any]]],
|
||||
clear_existing: bool
|
||||
) -> Tuple[int, int]:
|
||||
if not association_data:
|
||||
return 0, 0
|
||||
|
||||
restored_tables = 0
|
||||
restored_records = 0
|
||||
|
||||
if "server_squad_promo_groups" in association_data:
|
||||
restored = await self._restore_server_squad_promo_groups(
|
||||
db,
|
||||
association_data["server_squad_promo_groups"],
|
||||
clear_existing
|
||||
)
|
||||
restored_tables += 1
|
||||
restored_records += restored
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
async def _restore_server_squad_promo_groups(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
records: List[Dict[str, Any]],
|
||||
clear_existing: bool
|
||||
) -> int:
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
if clear_existing:
|
||||
await db.execute(server_squad_promo_groups.delete())
|
||||
|
||||
restored = 0
|
||||
|
||||
for record in records:
|
||||
server_id = record.get("server_squad_id")
|
||||
promo_id = record.get("promo_group_id")
|
||||
|
||||
if server_id is None or promo_id is None:
|
||||
logger.warning(
|
||||
"Пропущена некорректная запись server_squad_promo_groups: %s",
|
||||
record
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
server_squad_promo_groups.insert().values(
|
||||
server_squad_id=server_id,
|
||||
promo_group_id=promo_id
|
||||
)
|
||||
)
|
||||
restored += 1
|
||||
except IntegrityError:
|
||||
logger.debug(
|
||||
"Запись server_squad_promo_groups (%s, %s) уже существует",
|
||||
server_id,
|
||||
promo_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s",
|
||||
server_id,
|
||||
promo_id,
|
||||
e
|
||||
)
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
return restored
|
||||
|
||||
async def _restore_table_records(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
model,
|
||||
table_name: str,
|
||||
records: List[Dict[str, Any]],
|
||||
clear_existing: bool
|
||||
) -> int:
|
||||
restored_count = 0
|
||||
|
||||
for record_data in records:
|
||||
try:
|
||||
processed_data = self._process_record_data(record_data, model, table_name)
|
||||
|
||||
primary_key_col = self._get_primary_key_column(model)
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
existing_record = await db.execute(
|
||||
select(model).where(
|
||||
getattr(model, primary_key_col) == processed_data[primary_key_col]
|
||||
)
|
||||
)
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing and not clear_existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col:
|
||||
setattr(existing, key, value)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
|
||||
restored_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
|
||||
logger.error(f"Проблемные данные: {record_data}")
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
return restored_count
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession):
|
||||
tables_order = [
|
||||
"subscription_servers", "sent_notifications",
|
||||
"user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses", "transactions",
|
||||
"yookassa_payments", "cryptobot_payments", "welcome_texts",
|
||||
"subscriptions", "users", "promocodes", "server_squads",
|
||||
"squads", "service_rules", "system_settings", "monitoring_logs"
|
||||
"server_squad_promo_groups",
|
||||
"ticket_messages", "tickets", "support_audit_logs",
|
||||
"advertising_campaign_registrations", "advertising_campaigns",
|
||||
"subscription_servers", "sent_notifications",
|
||||
"discount_offers", "user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses",
|
||||
"yookassa_payments", "cryptobot_payments",
|
||||
"mulenpay_payments", "pal24_payments",
|
||||
"transactions", "welcome_texts", "subscriptions",
|
||||
"promocodes", "users", "promo_groups",
|
||||
"server_squads", "squads", "service_rules",
|
||||
"system_settings", "web_api_tokens", "monitoring_logs"
|
||||
]
|
||||
|
||||
for table_name in tables_order:
|
||||
@@ -566,6 +737,57 @@ class BackupService:
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {e}")
|
||||
|
||||
async def _collect_file_snapshots(self) -> Dict[str, Dict[str, Any]]:
|
||||
snapshots: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
app_config_path = settings.get_app_config_path()
|
||||
if app_config_path:
|
||||
path_obj = Path(app_config_path)
|
||||
if path_obj.exists() and path_obj.is_file():
|
||||
try:
|
||||
async with aiofiles.open(path_obj, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
snapshots["app_config"] = {
|
||||
"path": str(path_obj),
|
||||
"content": content,
|
||||
"modified_at": datetime.fromtimestamp(
|
||||
path_obj.stat().st_mtime
|
||||
).isoformat()
|
||||
}
|
||||
logger.info(
|
||||
"📁 Добавлен в бекап файл конфигурации: %s",
|
||||
path_obj
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка чтения файла конфигурации %s: %s",
|
||||
path_obj,
|
||||
e
|
||||
)
|
||||
|
||||
return snapshots
|
||||
|
||||
async def _restore_file_snapshots(self, file_snapshots: Dict[str, Dict[str, Any]]) -> int:
|
||||
restored_files = 0
|
||||
|
||||
if not file_snapshots:
|
||||
return restored_files
|
||||
|
||||
app_config_snapshot = file_snapshots.get("app_config")
|
||||
if app_config_snapshot:
|
||||
target_path = Path(settings.get_app_config_path())
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
async with aiofiles.open(target_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(app_config_snapshot.get("content", ""))
|
||||
restored_files += 1
|
||||
logger.info("📁 Файл app-config восстановлен по пути %s", target_path)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка восстановления файла %s: %s", target_path, e)
|
||||
|
||||
return restored_files
|
||||
|
||||
async def get_backup_list(self) -> List[Dict[str, Any]]:
|
||||
backups = []
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import BroadcastHistory
|
||||
from app.handlers.admin.messages import (
|
||||
create_broadcast_keyboard,
|
||||
get_custom_users,
|
||||
get_target_users,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_MEDIA_TYPES = {"photo", "video", "document"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BroadcastMediaConfig:
|
||||
type: str
|
||||
file_id: str
|
||||
caption: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BroadcastConfig:
|
||||
target: str
|
||||
message_text: str
|
||||
selected_buttons: list[str]
|
||||
media: Optional[BroadcastMediaConfig] = None
|
||||
initiator_name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _BroadcastTask:
|
||||
task: asyncio.Task
|
||||
cancel_event: asyncio.Event
|
||||
|
||||
|
||||
class BroadcastService:
|
||||
"""Handles broadcast execution triggered from the admin web API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._bot: Optional[Bot] = None
|
||||
self._tasks: dict[int, _BroadcastTask] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self._bot = bot
|
||||
|
||||
def is_running(self, broadcast_id: int) -> bool:
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
return bool(task_entry and not task_entry.task.done())
|
||||
|
||||
async def start_broadcast(self, broadcast_id: int, config: BroadcastConfig) -> None:
|
||||
if self._bot is None:
|
||||
logger.error("Невозможно запустить рассылку %s: бот не инициализирован", broadcast_id)
|
||||
await self._mark_failed(broadcast_id)
|
||||
return
|
||||
|
||||
cancel_event = asyncio.Event()
|
||||
|
||||
async with self._lock:
|
||||
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
|
||||
logger.warning("Рассылка %s уже запущена", broadcast_id)
|
||||
return
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._run_broadcast(broadcast_id, config, cancel_event),
|
||||
name=f"broadcast-{broadcast_id}",
|
||||
)
|
||||
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
|
||||
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
|
||||
|
||||
async def request_stop(self, broadcast_id: int) -> bool:
|
||||
async with self._lock:
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
if not task_entry:
|
||||
return False
|
||||
|
||||
task_entry.cancel_event.set()
|
||||
return True
|
||||
|
||||
async def _run_broadcast(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
config: BroadcastConfig,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> None:
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error("Запись рассылки %s не найдена в БД", broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.status = "in_progress"
|
||||
broadcast.sent_count = 0
|
||||
broadcast.failed_count = 0
|
||||
await session.commit()
|
||||
|
||||
recipients = await self._fetch_recipients(config.target)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error("Запись рассылки %s удалена до запуска", broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.total_count = len(recipients)
|
||||
await session.commit()
|
||||
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
if not recipients:
|
||||
logger.info("Рассылка %s: получатели не найдены", broadcast_id)
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
return
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
|
||||
for index, user in enumerate(recipients, start=1):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
sent_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
if index % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Критическая ошибка при выполнении рассылки %s: %s", broadcast_id, exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count)
|
||||
|
||||
async def _fetch_recipients(self, target: str):
|
||||
async with AsyncSessionLocal() as session:
|
||||
if target.startswith("custom_"):
|
||||
criteria = target[len("custom_"):]
|
||||
return await get_custom_users(session, criteria)
|
||||
return await get_target_users(session, target)
|
||||
|
||||
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
|
||||
if selected_buttons is None:
|
||||
selected_buttons = []
|
||||
return create_broadcast_keyboard(selected_buttons)
|
||||
|
||||
async def _deliver_message(
|
||||
self,
|
||||
telegram_id: int,
|
||||
config: BroadcastConfig,
|
||||
keyboard: Optional[InlineKeyboardMarkup],
|
||||
) -> None:
|
||||
if not self._bot:
|
||||
raise RuntimeError("Телеграм-бот не инициализирован")
|
||||
|
||||
if config.media and config.media.type in VALID_MEDIA_TYPES:
|
||||
caption = config.media.caption or config.message_text
|
||||
if config.media.type == "photo":
|
||||
await self._bot.send_photo(
|
||||
chat_id=telegram_id,
|
||||
photo=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == "video":
|
||||
await self._bot.send_video(
|
||||
chat_id=telegram_id,
|
||||
video=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == "document":
|
||||
await self._bot.send_document(
|
||||
chat_id=telegram_id,
|
||||
document=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
|
||||
await self._bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=config.message_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
async def _mark_finished(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
*,
|
||||
cancelled: bool,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count
|
||||
broadcast.status = "cancelled" if cancelled else (
|
||||
"completed" if failed_count == 0 else "partial"
|
||||
)
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _mark_cancelled(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
) -> None:
|
||||
await self._mark_finished(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled=True,
|
||||
)
|
||||
|
||||
async def _mark_failed(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int = 0,
|
||||
failed_count: int = 0,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count or broadcast.failed_count
|
||||
broadcast.status = "failed"
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
|
||||
broadcast_service = BroadcastService()
|
||||
|
||||
@@ -125,8 +125,24 @@ class AdvertisingCampaignService:
|
||||
)
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
if not squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
if trial_uuid:
|
||||
squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось подобрать сквад для кампании %s: %s",
|
||||
campaign.id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
@@ -135,6 +151,7 @@ class AdvertisingCampaignService:
|
||||
traffic_limit_gb=traffic_limit or 0,
|
||||
device_limit=device_limit,
|
||||
connected_squads=squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Утилиты для синхронизации токена внешней админки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SystemSetting
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ensure_external_admin_token(
|
||||
bot_username: Optional[str],
|
||||
bot_id: Optional[int],
|
||||
) -> Optional[str]:
|
||||
"""Генерирует и сохраняет токен внешней админки, если требуется."""
|
||||
|
||||
username_raw = (bot_username or "").strip()
|
||||
if not username_raw:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует",
|
||||
)
|
||||
return None
|
||||
|
||||
normalized_username = username_raw.lstrip("@").lower()
|
||||
if not normalized_username:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации",
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
token = settings.build_external_admin_token(normalized_username)
|
||||
except Exception as error: # pragma: no cover - защитный блок
|
||||
logger.error("❌ Ошибка генерации токена внешней админки: %s", error)
|
||||
return None
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SystemSetting.key, SystemSetting.value).where(
|
||||
SystemSetting.key.in_(
|
||||
["EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"]
|
||||
)
|
||||
)
|
||||
)
|
||||
rows = dict(result.all())
|
||||
existing_token = rows.get("EXTERNAL_ADMIN_TOKEN")
|
||||
existing_bot_id_raw = rows.get("EXTERNAL_ADMIN_TOKEN_BOT_ID")
|
||||
|
||||
existing_bot_id: Optional[int] = None
|
||||
if existing_bot_id_raw is not None:
|
||||
try:
|
||||
existing_bot_id = int(existing_bot_id_raw)
|
||||
except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений
|
||||
logger.warning(
|
||||
"⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки: %s",
|
||||
existing_bot_id_raw,
|
||||
)
|
||||
|
||||
if existing_token == token and existing_bot_id == bot_id:
|
||||
if settings.get_external_admin_token() != token:
|
||||
settings.EXTERNAL_ADMIN_TOKEN = token
|
||||
if settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id:
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
|
||||
return token
|
||||
|
||||
if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id:
|
||||
logger.error(
|
||||
"❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен %s, текущий %s",
|
||||
existing_bot_id,
|
||||
bot_id,
|
||||
)
|
||||
|
||||
try:
|
||||
await bot_configuration_service.reset_value(
|
||||
session,
|
||||
"EXTERNAL_ADMIN_TOKEN",
|
||||
force=True,
|
||||
)
|
||||
await bot_configuration_service.reset_value(
|
||||
session,
|
||||
"EXTERNAL_ADMIN_TOKEN_BOT_ID",
|
||||
force=True,
|
||||
)
|
||||
await session.commit()
|
||||
logger.warning(
|
||||
"⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота",
|
||||
)
|
||||
except Exception as cleanup_error: # pragma: no cover - защитный блок
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
"❌ Не удалось очистить токен внешней админки после обнаружения подмены: %s",
|
||||
cleanup_error,
|
||||
)
|
||||
finally:
|
||||
settings.EXTERNAL_ADMIN_TOKEN = None
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None
|
||||
|
||||
return None
|
||||
|
||||
updates: list[tuple[str, object]] = []
|
||||
if existing_token != token:
|
||||
updates.append(("EXTERNAL_ADMIN_TOKEN", token))
|
||||
|
||||
if bot_id is not None and existing_bot_id != bot_id:
|
||||
updates.append(("EXTERNAL_ADMIN_TOKEN_BOT_ID", bot_id))
|
||||
|
||||
if not updates:
|
||||
# Токен совпал, но могли отсутствовать значения в настройках приложения
|
||||
if settings.get_external_admin_token() != (existing_token or token):
|
||||
settings.EXTERNAL_ADMIN_TOKEN = existing_token or token
|
||||
if existing_bot_id is not None and (
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id
|
||||
):
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
|
||||
elif (
|
||||
bot_id is not None
|
||||
and settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != bot_id
|
||||
and existing_bot_id is None
|
||||
):
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id
|
||||
return existing_token or token
|
||||
|
||||
try:
|
||||
for key, value in updates:
|
||||
await bot_configuration_service.set_value(
|
||||
session,
|
||||
key,
|
||||
value,
|
||||
force=True,
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"✅ Токен внешней админки синхронизирован для @%s",
|
||||
normalized_username,
|
||||
)
|
||||
except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение
|
||||
await session.rollback()
|
||||
logger.warning(
|
||||
"⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа",
|
||||
)
|
||||
return None
|
||||
|
||||
return token
|
||||
except SQLAlchemyError as error:
|
||||
logger.error("❌ Ошибка сохранения токена внешней админки: %s", error)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.faq import (
|
||||
bulk_update_order,
|
||||
create_faq_page,
|
||||
delete_faq_page,
|
||||
get_faq_page_by_id,
|
||||
get_faq_pages,
|
||||
get_faq_setting,
|
||||
set_faq_enabled,
|
||||
update_faq_page,
|
||||
)
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FaqService:
|
||||
MAX_PAGE_LENGTH = 3500
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: str) -> str:
|
||||
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
|
||||
return base_language.split("-")[0].lower()
|
||||
|
||||
@staticmethod
|
||||
def normalize_language(language: str) -> str:
|
||||
return FaqService._normalize_language(language)
|
||||
|
||||
@classmethod
|
||||
async def get_setting(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = True,
|
||||
) -> Optional[FaqSetting]:
|
||||
lang = cls._normalize_language(language)
|
||||
setting = await get_faq_setting(db, lang)
|
||||
|
||||
if setting or not fallback:
|
||||
return setting
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
return await get_faq_setting(db, default_lang)
|
||||
|
||||
return setting
|
||||
|
||||
@classmethod
|
||||
async def is_enabled(cls, db: AsyncSession, language: str) -> bool:
|
||||
pages = await cls.get_pages(db, language)
|
||||
return bool(pages)
|
||||
|
||||
@classmethod
|
||||
async def set_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> FaqSetting:
|
||||
lang = cls._normalize_language(language)
|
||||
return await set_faq_enabled(db, lang, enabled)
|
||||
|
||||
@classmethod
|
||||
async def toggle_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> FaqSetting:
|
||||
lang = cls._normalize_language(language)
|
||||
setting = await get_faq_setting(db, lang)
|
||||
new_status = True
|
||||
if setting:
|
||||
new_status = not setting.is_enabled
|
||||
return await set_faq_enabled(db, lang, new_status)
|
||||
|
||||
@classmethod
|
||||
async def get_pages(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
fallback: bool = True,
|
||||
) -> List[FaqPage]:
|
||||
lang = cls._normalize_language(language)
|
||||
pages = await get_faq_pages(db, lang, include_inactive=include_inactive)
|
||||
|
||||
if pages:
|
||||
setting = await get_faq_setting(db, lang)
|
||||
if setting and not setting.is_enabled and not include_inactive:
|
||||
return []
|
||||
return pages
|
||||
|
||||
if not fallback:
|
||||
return []
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang == default_lang:
|
||||
return []
|
||||
|
||||
fallback_pages = await get_faq_pages(
|
||||
db,
|
||||
default_lang,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
if not fallback_pages:
|
||||
return []
|
||||
|
||||
setting = await get_faq_setting(db, default_lang)
|
||||
if setting and not setting.is_enabled and not include_inactive:
|
||||
return []
|
||||
|
||||
return fallback_pages
|
||||
|
||||
@classmethod
|
||||
async def get_page(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
page_id: int,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = True,
|
||||
include_inactive: bool = False,
|
||||
) -> Optional[FaqPage]:
|
||||
page = await get_faq_page_by_id(db, page_id)
|
||||
if not page:
|
||||
return None
|
||||
|
||||
lang = cls._normalize_language(language)
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
|
||||
if not include_inactive and not page.is_active:
|
||||
return None
|
||||
|
||||
if page.language == lang:
|
||||
return page
|
||||
|
||||
if fallback and page.language == default_lang:
|
||||
return page
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def create_page(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
language: str,
|
||||
title: str,
|
||||
content: str,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> FaqPage:
|
||||
lang = cls._normalize_language(language)
|
||||
is_active_value = True if is_active is None else bool(is_active)
|
||||
page = await create_faq_page(
|
||||
db,
|
||||
language=lang,
|
||||
title=title,
|
||||
content=content,
|
||||
display_order=display_order,
|
||||
is_active=is_active_value,
|
||||
)
|
||||
|
||||
setting = await get_faq_setting(db, lang)
|
||||
if not setting:
|
||||
await set_faq_enabled(db, lang, True)
|
||||
|
||||
return page
|
||||
|
||||
@classmethod
|
||||
async def update_page(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
page: FaqPage,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> FaqPage:
|
||||
return await update_faq_page(
|
||||
db,
|
||||
page,
|
||||
title=title,
|
||||
content=content,
|
||||
display_order=display_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def delete_page(cls, db: AsyncSession, page_id: int) -> None:
|
||||
await delete_faq_page(db, page_id)
|
||||
|
||||
@classmethod
|
||||
async def reorder_pages(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
pages: List[FaqPage],
|
||||
) -> None:
|
||||
lang = cls._normalize_language(language)
|
||||
ordered = [page for page in pages if page.language == lang]
|
||||
payload = [(page.id, index + 1) for index, page in enumerate(ordered)]
|
||||
await bulk_update_order(db, payload)
|
||||
|
||||
@staticmethod
|
||||
def split_content_into_pages(
|
||||
content: str,
|
||||
*,
|
||||
max_length: Optional[int] = None,
|
||||
) -> List[str]:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
limit = max_length or FaqService.MAX_PAGE_LENGTH
|
||||
if len(normalized) <= limit:
|
||||
return [normalized]
|
||||
|
||||
paragraphs = [
|
||||
paragraph.strip()
|
||||
for paragraph in normalized.split("\n\n")
|
||||
if paragraph.strip()
|
||||
]
|
||||
|
||||
pages: List[str] = []
|
||||
current = ""
|
||||
|
||||
def flush_current() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
pages.append(current.strip())
|
||||
current = ""
|
||||
|
||||
for paragraph in paragraphs:
|
||||
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
|
||||
if len(candidate) <= limit:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
flush_current()
|
||||
|
||||
if len(paragraph) <= limit:
|
||||
current = paragraph
|
||||
continue
|
||||
|
||||
start = 0
|
||||
while start < len(paragraph):
|
||||
chunk = paragraph[start : start + limit]
|
||||
pages.append(chunk.strip())
|
||||
start += limit
|
||||
|
||||
current = ""
|
||||
|
||||
flush_current()
|
||||
|
||||
if not pages:
|
||||
return [normalized[:limit]]
|
||||
|
||||
return pages
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
MainMenuButton,
|
||||
MainMenuButtonActionType,
|
||||
MainMenuButtonVisibility,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _MainMenuButtonData:
|
||||
text: str
|
||||
action_type: MainMenuButtonActionType
|
||||
action_value: str
|
||||
visibility: MainMenuButtonVisibility
|
||||
is_active: bool
|
||||
display_order: int
|
||||
|
||||
|
||||
class MainMenuButtonService:
|
||||
_cache: List[_MainMenuButtonData] | None = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def invalidate_cache(cls) -> None:
|
||||
cls._cache = None
|
||||
|
||||
@classmethod
|
||||
async def _load_cache(cls, db: AsyncSession) -> List[_MainMenuButtonData]:
|
||||
if cls._cache is not None:
|
||||
return cls._cache
|
||||
|
||||
async with cls._lock:
|
||||
if cls._cache is not None:
|
||||
return cls._cache
|
||||
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).order_by(
|
||||
MainMenuButton.display_order.asc(),
|
||||
MainMenuButton.id.asc(),
|
||||
)
|
||||
)
|
||||
|
||||
items: List[_MainMenuButtonData] = []
|
||||
for record in result.scalars().all():
|
||||
text = (record.text or "").strip()
|
||||
action_value = (record.action_value or "").strip()
|
||||
|
||||
if not text or not action_value:
|
||||
continue
|
||||
|
||||
try:
|
||||
action_type = MainMenuButtonActionType(record.action_type)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
try:
|
||||
visibility = MainMenuButtonVisibility(record.visibility)
|
||||
except ValueError:
|
||||
visibility = MainMenuButtonVisibility.ALL
|
||||
|
||||
items.append(
|
||||
_MainMenuButtonData(
|
||||
text=text,
|
||||
action_type=action_type,
|
||||
action_value=action_value,
|
||||
visibility=visibility,
|
||||
is_active=bool(record.is_active),
|
||||
display_order=int(record.display_order or 0),
|
||||
)
|
||||
)
|
||||
|
||||
cls._cache = items
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
async def get_buttons_for_user(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
is_admin: bool,
|
||||
has_active_subscription: bool,
|
||||
subscription_is_active: bool,
|
||||
) -> list[InlineKeyboardButton]:
|
||||
data = await cls._load_cache(db)
|
||||
has_subscription = bool(has_active_subscription and subscription_is_active)
|
||||
|
||||
buttons: list[InlineKeyboardButton] = []
|
||||
for item in data:
|
||||
if not item.is_active:
|
||||
continue
|
||||
|
||||
if item.visibility == MainMenuButtonVisibility.ADMINS and not is_admin:
|
||||
continue
|
||||
|
||||
if item.visibility == MainMenuButtonVisibility.SUBSCRIBERS and not has_subscription:
|
||||
continue
|
||||
|
||||
button = cls._build_button(item)
|
||||
if button:
|
||||
buttons.append(button)
|
||||
|
||||
return buttons
|
||||
|
||||
@staticmethod
|
||||
def _build_button(item: _MainMenuButtonData) -> InlineKeyboardButton | None:
|
||||
if item.action_type == MainMenuButtonActionType.URL:
|
||||
return InlineKeyboardButton(text=item.text, url=item.action_value)
|
||||
|
||||
if item.action_type == MainMenuButtonActionType.MINI_APP:
|
||||
return InlineKeyboardButton(
|
||||
text=item.text,
|
||||
web_app=types.WebAppInfo(url=item.action_value),
|
||||
)
|
||||
|
||||
return None
|
||||
+1067
-63
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MulenPayService:
|
||||
"""Интеграция с Mulen Pay API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.api_key = settings.MULENPAY_API_KEY
|
||||
self.shop_id = settings.MULENPAY_SHOP_ID
|
||||
self.secret_key = settings.MULENPAY_SECRET_KEY
|
||||
self.base_url = settings.MULENPAY_BASE_URL.rstrip("/")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(
|
||||
settings.is_mulenpay_enabled()
|
||||
and self.api_key
|
||||
and self.shop_id
|
||||
and self.secret_key
|
||||
)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("MulenPay service is not configured")
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"MulenPay API error %s %s: %s", response.status, endpoint, data
|
||||
)
|
||||
return None
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("MulenPay API request error: %s", error)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount_kopeks: int) -> str:
|
||||
return f"{amount_kopeks / 100:.2f}"
|
||||
|
||||
def _build_signature(self, currency: str, amount_str: str) -> str:
|
||||
raw_string = f"{currency}{amount_str}{self.shop_id}{self.secret_key}".encode()
|
||||
return hashlib.sha1(raw_string).hexdigest()
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
uuid: str,
|
||||
items: list,
|
||||
language: str = "ru",
|
||||
subscribe: Optional[str] = None,
|
||||
hold_time: Optional[int] = None,
|
||||
website_url: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("MulenPay service is not configured")
|
||||
return None
|
||||
|
||||
amount_str = self._format_amount(amount_kopeks)
|
||||
currency = "rub"
|
||||
payload = {
|
||||
"currency": currency,
|
||||
"amount": amount_str,
|
||||
"uuid": uuid,
|
||||
"shopId": self.shop_id,
|
||||
"description": description,
|
||||
"items": items,
|
||||
"language": language,
|
||||
"sign": self._build_signature(currency, amount_str),
|
||||
}
|
||||
|
||||
if subscribe:
|
||||
payload["subscribe"] = subscribe
|
||||
if hold_time is not None:
|
||||
payload["holdTime"] = hold_time
|
||||
if website_url:
|
||||
payload["website_url"] = website_url
|
||||
|
||||
response = await self._request("POST", "/v2/payments", json_data=payload)
|
||||
if not response or not response.get("success"):
|
||||
logger.error("Failed to create MulenPay payment: %s", response)
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("GET", f"/v2/payments/{payment_id}")
|
||||
@@ -0,0 +1,257 @@
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationSettingsService:
|
||||
"""Runtime-editable notification settings stored on disk."""
|
||||
|
||||
_storage_path: Path = Path("data/notification_settings.json")
|
||||
_data: Dict[str, Dict[str, Any]] = {}
|
||||
_loaded: bool = False
|
||||
|
||||
_DEFAULTS: Dict[str, Dict[str, Any]] = {
|
||||
"trial_inactive_1h": {"enabled": True},
|
||||
"trial_inactive_24h": {"enabled": True},
|
||||
"trial_channel_unsubscribed": {"enabled": True},
|
||||
"expired_1d": {"enabled": True},
|
||||
"expired_second_wave": {
|
||||
"enabled": True,
|
||||
"discount_percent": 10,
|
||||
"valid_hours": 24,
|
||||
},
|
||||
"expired_third_wave": {
|
||||
"enabled": True,
|
||||
"discount_percent": 20,
|
||||
"valid_hours": 24,
|
||||
"trigger_days": 5,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _ensure_dir(cls) -> None:
|
||||
try:
|
||||
cls._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc: # pragma: no cover - filesystem guard
|
||||
logger.error("Failed to create notification settings dir: %s", exc)
|
||||
|
||||
@classmethod
|
||||
def _load(cls) -> None:
|
||||
if cls._loaded:
|
||||
return
|
||||
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
if cls._storage_path.exists():
|
||||
raw = cls._storage_path.read_text(encoding="utf-8")
|
||||
cls._data = json.loads(raw) if raw.strip() else {}
|
||||
else:
|
||||
cls._data = {}
|
||||
except Exception as exc:
|
||||
logger.error("Failed to load notification settings: %s", exc)
|
||||
cls._data = {}
|
||||
|
||||
changed = cls._apply_defaults()
|
||||
if changed:
|
||||
cls._save()
|
||||
cls._loaded = True
|
||||
|
||||
@classmethod
|
||||
def _apply_defaults(cls) -> bool:
|
||||
changed = False
|
||||
for key, defaults in cls._DEFAULTS.items():
|
||||
current = cls._data.get(key)
|
||||
if not isinstance(current, dict):
|
||||
cls._data[key] = deepcopy(defaults)
|
||||
changed = True
|
||||
continue
|
||||
|
||||
for def_key, def_value in defaults.items():
|
||||
if def_key not in current:
|
||||
current[def_key] = def_value
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
@classmethod
|
||||
def _save(cls) -> bool:
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
cls._storage_path.write_text(
|
||||
json.dumps(cls._data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to save notification settings: %s", exc)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _get(cls, key: str) -> Dict[str, Any]:
|
||||
cls._load()
|
||||
value = cls._data.get(key)
|
||||
if not isinstance(value, dict):
|
||||
value = deepcopy(cls._DEFAULTS.get(key, {}))
|
||||
cls._data[key] = value
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Dict[str, Any]]:
|
||||
cls._load()
|
||||
return deepcopy(cls._data)
|
||||
|
||||
@classmethod
|
||||
def _set_field(cls, key: str, field: str, value: Any) -> bool:
|
||||
cls._load()
|
||||
section = cls._get(key)
|
||||
section[field] = value
|
||||
cls._data[key] = section
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def set_enabled(cls, key: str, enabled: bool) -> bool:
|
||||
return cls._set_field(key, "enabled", bool(enabled))
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls, key: str) -> bool:
|
||||
return bool(cls._get(key).get("enabled", True))
|
||||
|
||||
# Trial inactivity helpers
|
||||
@classmethod
|
||||
def is_trial_inactive_1h_enabled(cls) -> bool:
|
||||
return cls.is_enabled("trial_inactive_1h")
|
||||
|
||||
@classmethod
|
||||
def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("trial_inactive_1h", enabled)
|
||||
|
||||
@classmethod
|
||||
def is_trial_inactive_24h_enabled(cls) -> bool:
|
||||
return cls.is_enabled("trial_inactive_24h")
|
||||
|
||||
@classmethod
|
||||
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("trial_inactive_24h", enabled)
|
||||
|
||||
@classmethod
|
||||
def is_trial_channel_unsubscribed_enabled(cls) -> bool:
|
||||
return cls.is_enabled("trial_channel_unsubscribed")
|
||||
|
||||
@classmethod
|
||||
def set_trial_channel_unsubscribed_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("trial_channel_unsubscribed", enabled)
|
||||
|
||||
# Expired subscription notifications
|
||||
@classmethod
|
||||
def is_expired_1d_enabled(cls) -> bool:
|
||||
return cls.is_enabled("expired_1d")
|
||||
|
||||
@classmethod
|
||||
def set_expired_1d_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("expired_1d", enabled)
|
||||
|
||||
@classmethod
|
||||
def is_second_wave_enabled(cls) -> bool:
|
||||
return cls.is_enabled("expired_second_wave")
|
||||
|
||||
@classmethod
|
||||
def set_second_wave_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("expired_second_wave", enabled)
|
||||
|
||||
@classmethod
|
||||
def get_second_wave_discount_percent(cls) -> int:
|
||||
value = cls._get("expired_second_wave").get("discount_percent", 10)
|
||||
try:
|
||||
return max(0, min(100, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 10
|
||||
|
||||
@classmethod
|
||||
def set_second_wave_discount_percent(cls, percent: int) -> bool:
|
||||
try:
|
||||
percent_int = max(0, min(100, int(percent)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_second_wave", "discount_percent", percent_int)
|
||||
|
||||
@classmethod
|
||||
def get_second_wave_valid_hours(cls) -> int:
|
||||
value = cls._get("expired_second_wave").get("valid_hours", 24)
|
||||
try:
|
||||
return max(1, min(168, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 24
|
||||
|
||||
@classmethod
|
||||
def set_second_wave_valid_hours(cls, hours: int) -> bool:
|
||||
try:
|
||||
hours_int = max(1, min(168, int(hours)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_second_wave", "valid_hours", hours_int)
|
||||
|
||||
@classmethod
|
||||
def is_third_wave_enabled(cls) -> bool:
|
||||
return cls.is_enabled("expired_third_wave")
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("expired_third_wave", enabled)
|
||||
|
||||
@classmethod
|
||||
def get_third_wave_discount_percent(cls) -> int:
|
||||
value = cls._get("expired_third_wave").get("discount_percent", 20)
|
||||
try:
|
||||
return max(0, min(100, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 20
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_discount_percent(cls, percent: int) -> bool:
|
||||
try:
|
||||
percent_int = max(0, min(100, int(percent)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_third_wave", "discount_percent", percent_int)
|
||||
|
||||
@classmethod
|
||||
def get_third_wave_valid_hours(cls) -> int:
|
||||
value = cls._get("expired_third_wave").get("valid_hours", 24)
|
||||
try:
|
||||
return max(1, min(168, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 24
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_valid_hours(cls, hours: int) -> bool:
|
||||
try:
|
||||
hours_int = max(1, min(168, int(hours)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_third_wave", "valid_hours", hours_int)
|
||||
|
||||
@classmethod
|
||||
def get_third_wave_trigger_days(cls) -> int:
|
||||
value = cls._get("expired_third_wave").get("trigger_days", 5)
|
||||
try:
|
||||
return max(2, min(60, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 5
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_trigger_days(cls, days: int) -> bool:
|
||||
try:
|
||||
days_int = max(2, min(60, int(days)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_third_wave", "trigger_days", days_int)
|
||||
|
||||
@classmethod
|
||||
def are_notifications_globally_enabled(cls) -> bool:
|
||||
return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True))
|
||||
@@ -0,0 +1,119 @@
|
||||
"""High level integration with PayPalych API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.external.pal24_client import Pal24Client, Pal24APIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pal24Service:
|
||||
"""Wrapper around :class:`Pal24Client` providing domain helpers."""
|
||||
|
||||
BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"}
|
||||
BILL_FAILED_STATES = {"FAIL", "CANCELLED"}
|
||||
BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"}
|
||||
|
||||
def __init__(self, client: Optional[Pal24Client] = None) -> None:
|
||||
self.client = client or Pal24Client()
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self.client.is_configured and settings.is_pal24_enabled()
|
||||
|
||||
async def create_bill(
|
||||
self,
|
||||
*,
|
||||
amount_kopeks: int,
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
description: str,
|
||||
ttl_seconds: Optional[int] = None,
|
||||
custom_payload: Optional[Dict[str, Any]] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not self.is_configured:
|
||||
raise Pal24APIError("Pal24 service is not configured")
|
||||
|
||||
amount_decimal = Pal24Client.normalize_amount(amount_kopeks)
|
||||
extra_payload: Dict[str, Any] = {
|
||||
"custom": custom_payload or {},
|
||||
"ttl": ttl_seconds,
|
||||
}
|
||||
|
||||
if payer_email:
|
||||
extra_payload["payer_email"] = payer_email
|
||||
if payment_method:
|
||||
extra_payload["payment_method"] = payment_method
|
||||
|
||||
filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})}
|
||||
|
||||
logger.info(
|
||||
"Создаем Pal24 счет: user_id=%s, order_id=%s, amount=%s, ttl=%s",
|
||||
user_id,
|
||||
order_id,
|
||||
amount_decimal,
|
||||
ttl_seconds,
|
||||
)
|
||||
|
||||
response = await self.client.create_bill(
|
||||
amount=amount_decimal,
|
||||
shop_id=settings.PAL24_SHOP_ID,
|
||||
order_id=order_id,
|
||||
description=description,
|
||||
type_="normal",
|
||||
**filtered_payload,
|
||||
)
|
||||
|
||||
logger.info("Pal24 счет создан: %s", response)
|
||||
return response
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
|
||||
logger.debug("Запрашиваем статус Pal24 счета %s", bill_id)
|
||||
return await self.client.get_bill_status(bill_id)
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
|
||||
logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id)
|
||||
return await self.client.get_payment_status(payment_id)
|
||||
|
||||
@staticmethod
|
||||
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
|
||||
missing = [field for field in required_fields if field not in payload]
|
||||
if missing:
|
||||
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
|
||||
|
||||
inv_id = str(payload["InvId"])
|
||||
out_sum = str(payload["OutSum"])
|
||||
signature = str(payload["SignatureValue"])
|
||||
|
||||
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
|
||||
raise Pal24APIError("Pal24 postback signature mismatch")
|
||||
|
||||
logger.info(
|
||||
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
|
||||
inv_id,
|
||||
payload.get("Status"),
|
||||
payload.get("TrsId"),
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def convert_to_kopeks(amount: str) -> int:
|
||||
decimal_amount = Decimal(str(amount))
|
||||
return int((decimal_amount * Decimal("100")).quantize(Decimal("1")))
|
||||
|
||||
@staticmethod
|
||||
def get_expiration(ttl_seconds: Optional[int]) -> Optional[datetime]:
|
||||
if not ttl_seconds:
|
||||
return None
|
||||
return datetime.utcnow() + timedelta(seconds=ttl_seconds)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
@@ -26,6 +28,24 @@ from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.database.crud.mulenpay import (
|
||||
create_mulenpay_payment,
|
||||
get_mulenpay_payment_by_local_id,
|
||||
get_mulenpay_payment_by_uuid,
|
||||
get_mulenpay_payment_by_mulen_id,
|
||||
update_mulenpay_payment_status,
|
||||
link_mulenpay_payment_to_transaction,
|
||||
)
|
||||
from app.database.crud.pal24 import (
|
||||
create_pal24_payment,
|
||||
get_pal24_payment_by_bill_id,
|
||||
get_pal24_payment_by_id,
|
||||
get_pal24_payment_by_order_id,
|
||||
link_pal24_payment_to_transaction,
|
||||
update_pal24_payment_status,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,6 +57,8 @@ class PaymentService:
|
||||
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
|
||||
self.stars_service = TelegramStarsService(bot) if bot else None
|
||||
self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None
|
||||
self.mulenpay_service = MulenPayService() if settings.is_mulenpay_enabled() else None
|
||||
self.pal24_service = Pal24Service() if settings.is_pal24_enabled() else None
|
||||
|
||||
async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language if user else "ru")
|
||||
@@ -633,7 +655,7 @@ class PaymentService:
|
||||
description: str = "Пополнение баланса",
|
||||
payload: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
if not self.cryptobot_service:
|
||||
logger.error("CryptoBot сервис не инициализирован")
|
||||
return None
|
||||
@@ -686,7 +708,709 @@ class PaymentService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_mulenpay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.mulenpay_service:
|
||||
logger.error("MulenPay сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payment_uuid = f"mulen_{user_id}_{uuid.uuid4().hex}"
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
items = [
|
||||
{
|
||||
"description": description[:128],
|
||||
"quantity": 1,
|
||||
"price": round(amount_rubles, 2),
|
||||
"vat_code": settings.MULENPAY_VAT_CODE,
|
||||
"payment_subject": settings.MULENPAY_PAYMENT_SUBJECT,
|
||||
"payment_mode": settings.MULENPAY_PAYMENT_MODE,
|
||||
}
|
||||
]
|
||||
|
||||
response = await self.mulenpay_service.create_payment(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
uuid=payment_uuid,
|
||||
items=items,
|
||||
language=language or settings.MULENPAY_LANGUAGE,
|
||||
website_url=settings.WEBHOOK_URL,
|
||||
)
|
||||
|
||||
if not response:
|
||||
logger.error("Ошибка создания MulenPay платежа")
|
||||
return None
|
||||
|
||||
mulen_payment_id = response.get("id")
|
||||
payment_url = response.get("paymentUrl")
|
||||
|
||||
metadata = {
|
||||
"user_id": user_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
local_payment = await create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
uuid=payment_uuid,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
mulen_payment_id=mulen_payment_id,
|
||||
currency="RUB",
|
||||
status="created",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж %s на %s₽ для пользователя %s",
|
||||
mulen_payment_id,
|
||||
amount_rubles,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"mulen_payment_id": mulen_payment_id,
|
||||
"payment_url": payment_url,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"uuid": payment_uuid,
|
||||
"status": "created",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания MulenPay платежа: {e}")
|
||||
return None
|
||||
|
||||
async def create_pal24_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
language: str,
|
||||
ttl_seconds: Optional[int] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.pal24_service or not self.pal24_service.is_configured:
|
||||
logger.error("Pal24 сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Pal24 меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Pal24 больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.PAL24_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
order_id = f"pal24_{user_id}_{uuid.uuid4().hex}"
|
||||
|
||||
custom_payload = {
|
||||
"user_id": user_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.pal24_service.create_bill(
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
description=description,
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
payment_method="SBP",
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
return None
|
||||
|
||||
if not response.get("success", True):
|
||||
logger.error("Pal24 вернул ошибку при создании счета: %s", response)
|
||||
return None
|
||||
|
||||
bill_id = response.get("bill_id")
|
||||
if not bill_id:
|
||||
logger.error("Pal24 не вернул bill_id: %s", response)
|
||||
return None
|
||||
|
||||
def _pick_url(*keys: str) -> Optional[str]:
|
||||
for key in keys:
|
||||
value = response.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
transfer_url = _pick_url(
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_url = _pick_url(
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
link_page_url = _pick_url(
|
||||
"link_page_url",
|
||||
"linkPageUrl",
|
||||
"page_url",
|
||||
"pageUrl",
|
||||
)
|
||||
|
||||
primary_link = transfer_url or link_page_url or card_url
|
||||
secondary_link = link_page_url or card_url or transfer_url
|
||||
|
||||
metadata_links = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"sbp": transfer_url,
|
||||
"card": card_url,
|
||||
"page": link_page_url,
|
||||
}.items()
|
||||
if value
|
||||
}
|
||||
|
||||
payment = await create_pal24_payment(
|
||||
db,
|
||||
user_id=user_id,
|
||||
bill_id=bill_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
status=response.get("status", "NEW"),
|
||||
type_=response.get("type", "normal"),
|
||||
currency=response.get("currency", "RUB"),
|
||||
link_url=primary_link,
|
||||
link_page_url=secondary_link,
|
||||
ttl=ttl_seconds,
|
||||
metadata={
|
||||
"raw_response": response,
|
||||
"language": language,
|
||||
**({"links": metadata_links} if metadata_links else {}),
|
||||
},
|
||||
)
|
||||
|
||||
payment_info = {
|
||||
"bill_id": bill_id,
|
||||
"order_id": order_id,
|
||||
"link_url": primary_link,
|
||||
"link_page_url": secondary_link,
|
||||
"local_payment_id": payment.id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"sbp_url": transfer_url or primary_link,
|
||||
"card_url": card_url,
|
||||
"transfer_url": transfer_url,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Создан Pal24 счет %s для пользователя %s на сумму %s",
|
||||
bill_id,
|
||||
user_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
|
||||
return payment_info
|
||||
|
||||
async def process_mulenpay_callback(self, db: AsyncSession, callback_data: dict) -> bool:
|
||||
try:
|
||||
uuid_value = callback_data.get("uuid")
|
||||
payment_status = (callback_data.get("payment_status") or "").lower()
|
||||
mulen_payment_id_raw = callback_data.get("id")
|
||||
mulen_payment_id_int: Optional[int] = None
|
||||
if mulen_payment_id_raw is not None:
|
||||
try:
|
||||
mulen_payment_id_int = int(mulen_payment_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
mulen_payment_id_int = None
|
||||
amount_value = callback_data.get("amount")
|
||||
|
||||
if not uuid_value and mulen_payment_id_raw is None:
|
||||
logger.error("MulenPay callback без uuid и id")
|
||||
return False
|
||||
|
||||
payment = None
|
||||
if uuid_value:
|
||||
payment = await get_mulenpay_payment_by_uuid(db, uuid_value)
|
||||
|
||||
if not payment and mulen_payment_id_int is not None:
|
||||
payment = await get_mulenpay_payment_by_mulen_id(db, mulen_payment_id_int)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"MulenPay платеж не найден (uuid=%s, id=%s)",
|
||||
uuid_value,
|
||||
mulen_payment_id_raw,
|
||||
)
|
||||
return False
|
||||
|
||||
if payment.transaction_id and payment.is_paid:
|
||||
logger.info("MulenPay платеж %s уже обработан", payment.uuid)
|
||||
return True
|
||||
|
||||
paid_at = datetime.utcnow()
|
||||
|
||||
if payment_status == "success":
|
||||
try:
|
||||
amount_kopeks = int(Decimal(str(amount_value)) * 100)
|
||||
except (InvalidOperation, TypeError):
|
||||
amount_kopeks = payment.amount_kopeks
|
||||
logger.warning(
|
||||
"Не удалось распарсить сумму MulenPay, используем значение из БД: %s",
|
||||
amount_value,
|
||||
)
|
||||
|
||||
if amount_kopeks != payment.amount_kopeks:
|
||||
logger.warning(
|
||||
"Несовпадение суммы MulenPay: callback=%s, ожидаемо=%s",
|
||||
amount_kopeks,
|
||||
payment.amount_kopeks,
|
||||
)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Mulen Pay ({mulen_payment_id_raw})",
|
||||
payment_method=PaymentMethod.MULENPAY,
|
||||
external_id=(
|
||||
str(mulen_payment_id_int)
|
||||
if mulen_payment_id_int is not None
|
||||
else payment.uuid
|
||||
),
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await link_mulenpay_payment_to_transaction(
|
||||
db,
|
||||
payment=payment,
|
||||
transaction_id=transaction.id,
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для MulenPay платежа", payment.user_id)
|
||||
return False
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot)
|
||||
except Exception as referral_error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения MulenPay: %s",
|
||||
referral_error,
|
||||
)
|
||||
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status="success",
|
||||
is_paid=True,
|
||||
paid_at=paid_at,
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления MulenPay: %s",
|
||||
notify_error,
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: Mulen Pay\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as user_notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю MulenPay: %s",
|
||||
user_notify_error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан MulenPay платеж %s для пользователя %s",
|
||||
payment.uuid,
|
||||
payment.user_id,
|
||||
)
|
||||
return True
|
||||
|
||||
if payment_status == "cancel":
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status="canceled",
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info("MulenPay платеж %s отменен", payment.uuid)
|
||||
return True
|
||||
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=payment_status or "unknown",
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info(
|
||||
"Получен MulenPay callback со статусом %s для платежа %s",
|
||||
payment_status,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обработки MulenPay callback: {error}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def process_pal24_postback(self, db: AsyncSession, payload: Dict[str, Any]) -> bool:
|
||||
|
||||
if not self.pal24_service or not self.pal24_service.is_configured:
|
||||
logger.error("Pal24 сервис не инициализирован")
|
||||
return False
|
||||
|
||||
try:
|
||||
order_id_raw = payload.get("InvId")
|
||||
order_id = str(order_id_raw) if order_id_raw is not None else None
|
||||
if not order_id:
|
||||
logger.error("Pal24 postback без InvId")
|
||||
return False
|
||||
|
||||
payment = await get_pal24_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
bill_id = payload.get("BillId")
|
||||
if bill_id:
|
||||
payment = await get_pal24_payment_by_bill_id(db, str(bill_id))
|
||||
|
||||
if not payment:
|
||||
logger.error("Pal24 платеж не найден для order_id=%s", order_id)
|
||||
return False
|
||||
|
||||
if payment.transaction_id and payment.is_paid:
|
||||
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
|
||||
return True
|
||||
|
||||
status = str(payload.get("Status", "UNKNOWN")).upper()
|
||||
payment_id = payload.get("TrsId")
|
||||
balance_amount = payload.get("BalanceAmount")
|
||||
balance_currency = payload.get("BalanceCurrency")
|
||||
payer_account = payload.get("AccountNumber")
|
||||
payment_method = payload.get("AccountType")
|
||||
|
||||
try:
|
||||
amount_kopeks = Pal24Service.convert_to_kopeks(str(payload.get("OutSum")))
|
||||
except Exception:
|
||||
logger.warning("Не удалось распарсить сумму Pal24, используем сохраненное значение")
|
||||
amount_kopeks = payment.amount_kopeks
|
||||
|
||||
if amount_kopeks != payment.amount_kopeks:
|
||||
logger.warning(
|
||||
"Несовпадение суммы Pal24: callback=%s, ожидаемо=%s",
|
||||
amount_kopeks,
|
||||
payment.amount_kopeks,
|
||||
)
|
||||
|
||||
is_success = status in Pal24Service.BILL_SUCCESS_STATES
|
||||
is_failed = status in Pal24Service.BILL_FAILED_STATES
|
||||
|
||||
await update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status,
|
||||
is_active=not is_failed,
|
||||
is_paid=is_success,
|
||||
payment_id=str(payment_id) if payment_id else None,
|
||||
payment_status=status,
|
||||
payment_method=str(payment_method) if payment_method else None,
|
||||
balance_amount=str(balance_amount) if balance_amount is not None else None,
|
||||
balance_currency=str(balance_currency) if balance_currency is not None else None,
|
||||
payer_account=str(payer_account) if payer_account is not None else None,
|
||||
callback_payload=payload,
|
||||
)
|
||||
|
||||
if not is_success:
|
||||
logger.info(
|
||||
"Получен Pal24 статус %s для платежа %s (успех=%s)",
|
||||
status,
|
||||
payment.bill_id,
|
||||
is_success,
|
||||
)
|
||||
return True
|
||||
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Pal24 платежа", payment.user_id)
|
||||
return False
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Pal24 ({payment_id})",
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot)
|
||||
except Exception as referral_error:
|
||||
logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error("Ошибка отправки админ уведомления Pal24: %s", notify_error)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: PayPalych\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as user_notify_error:
|
||||
logger.error("Ошибка отправки уведомления пользователю Pal24: %s", user_notify_error)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Pal24 платеж %s для пользователя %s",
|
||||
payment.bill_id,
|
||||
payment.user_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _map_mulenpay_status(status_code: Optional[int]) -> str:
|
||||
mapping = {
|
||||
0: "created",
|
||||
1: "processing",
|
||||
2: "canceled",
|
||||
3: "success",
|
||||
4: "error",
|
||||
5: "hold",
|
||||
6: "hold",
|
||||
}
|
||||
return mapping.get(status_code, "unknown")
|
||||
|
||||
async def get_mulenpay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
payment = await get_mulenpay_payment_by_local_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_status_code = None
|
||||
remote_data = None
|
||||
|
||||
if (
|
||||
self.mulenpay_service
|
||||
and payment.mulen_payment_id is not None
|
||||
):
|
||||
response = await self.mulenpay_service.get_payment(payment.mulen_payment_id)
|
||||
if response and response.get("success"):
|
||||
remote_data = response.get("payment")
|
||||
if isinstance(remote_data, dict):
|
||||
remote_status_code = remote_data.get("status")
|
||||
mapped_status = self._map_mulenpay_status(remote_status_code)
|
||||
|
||||
if mapped_status == "success" and not payment.is_paid:
|
||||
await self.process_mulenpay_callback(
|
||||
db,
|
||||
{
|
||||
"uuid": payment.uuid,
|
||||
"payment_status": "success",
|
||||
"id": remote_data.get("id"),
|
||||
"amount": remote_data.get("amount"),
|
||||
},
|
||||
)
|
||||
payment = await get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
elif mapped_status and mapped_status != payment.status:
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=mapped_status,
|
||||
mulen_payment_id=remote_data.get("id"),
|
||||
)
|
||||
payment = await get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status_code": remote_status_code,
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения статуса MulenPay: {error}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_pal24_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
payment = await get_pal24_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_status = None
|
||||
remote_data = None
|
||||
|
||||
if self.pal24_service and payment.bill_id:
|
||||
try:
|
||||
response = await self.pal24_service.get_bill_status(payment.bill_id)
|
||||
remote_data = response
|
||||
remote_status = (
|
||||
response.get("status")
|
||||
or response.get("bill", {}).get("status")
|
||||
)
|
||||
|
||||
if remote_status and remote_status != payment.status:
|
||||
await update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=str(remote_status).upper(),
|
||||
)
|
||||
payment = await get_pal24_payment_by_id(db, local_payment_id)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при получении статуса: %s", error)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status": remote_status,
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
|
||||
try:
|
||||
from app.database.crud.cryptobot import (
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.privacy_policy import (
|
||||
get_privacy_policy,
|
||||
set_privacy_policy_enabled,
|
||||
upsert_privacy_policy,
|
||||
)
|
||||
from app.database.models import PrivacyPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PrivacyPolicyService:
|
||||
"""Utility helpers around privacy policy storage and presentation."""
|
||||
|
||||
MAX_PAGE_LENGTH = 3500
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: str) -> str:
|
||||
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
|
||||
return base_language.split("-")[0].lower()
|
||||
|
||||
@staticmethod
|
||||
def normalize_language(language: str) -> str:
|
||||
return PrivacyPolicyService._normalize_language(language)
|
||||
|
||||
@classmethod
|
||||
async def get_policy(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = False,
|
||||
) -> Optional[PrivacyPolicy]:
|
||||
lang = cls._normalize_language(language)
|
||||
policy = await get_privacy_policy(db, lang)
|
||||
|
||||
if policy or not fallback:
|
||||
return policy
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
return await get_privacy_policy(db, default_lang)
|
||||
|
||||
return policy
|
||||
|
||||
@classmethod
|
||||
async def get_active_policy(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> Optional[PrivacyPolicy]:
|
||||
lang = cls._normalize_language(language)
|
||||
policy = await get_privacy_policy(db, lang)
|
||||
|
||||
if policy and policy.is_enabled and policy.content.strip():
|
||||
return policy
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
fallback_policy = await get_privacy_policy(db, default_lang)
|
||||
if fallback_policy and fallback_policy.is_enabled and fallback_policy.content.strip():
|
||||
return fallback_policy
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def is_policy_enabled(cls, db: AsyncSession, language: str) -> bool:
|
||||
policy = await cls.get_active_policy(db, language)
|
||||
return policy is not None
|
||||
|
||||
@classmethod
|
||||
async def save_policy(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
) -> PrivacyPolicy:
|
||||
lang = cls._normalize_language(language)
|
||||
enable_if_new = True
|
||||
policy = await upsert_privacy_policy(
|
||||
db,
|
||||
lang,
|
||||
content,
|
||||
enable_if_new=enable_if_new,
|
||||
)
|
||||
logger.info("✅ Политика конфиденциальности обновлена для языка %s", lang)
|
||||
return policy
|
||||
|
||||
@classmethod
|
||||
async def set_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PrivacyPolicy:
|
||||
lang = cls._normalize_language(language)
|
||||
return await set_privacy_policy_enabled(db, lang, enabled)
|
||||
|
||||
@classmethod
|
||||
async def toggle_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> PrivacyPolicy:
|
||||
lang = cls._normalize_language(language)
|
||||
policy = await get_privacy_policy(db, lang)
|
||||
|
||||
if policy:
|
||||
new_status = not policy.is_enabled
|
||||
else:
|
||||
new_status = True
|
||||
|
||||
return await set_privacy_policy_enabled(db, lang, new_status)
|
||||
|
||||
@staticmethod
|
||||
def split_content_into_pages(
|
||||
content: str,
|
||||
*,
|
||||
max_length: int = None,
|
||||
) -> List[str]:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
max_len = max_length or PrivacyPolicyService.MAX_PAGE_LENGTH
|
||||
|
||||
if len(normalized) <= max_len:
|
||||
return [normalized]
|
||||
|
||||
paragraphs = [
|
||||
paragraph.strip()
|
||||
for paragraph in normalized.split("\n\n")
|
||||
if paragraph.strip()
|
||||
]
|
||||
|
||||
pages: List[str] = []
|
||||
current = ""
|
||||
|
||||
def flush_current() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
pages.append(current.strip())
|
||||
current = ""
|
||||
|
||||
for paragraph in paragraphs:
|
||||
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
|
||||
if len(candidate) <= max_len:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
flush_current()
|
||||
|
||||
if len(paragraph) <= max_len:
|
||||
current = paragraph
|
||||
continue
|
||||
|
||||
start_index = 0
|
||||
while start_index < len(paragraph):
|
||||
chunk = paragraph[start_index:start_index + max_len]
|
||||
pages.append(chunk.strip())
|
||||
start_index += max_len
|
||||
|
||||
current = ""
|
||||
|
||||
flush_current()
|
||||
|
||||
if not pages:
|
||||
return [normalized[:max_len]]
|
||||
|
||||
return pages
|
||||
@@ -0,0 +1,189 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _notify_admins_about_auto_assignment(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
old_group: Optional[PromoGroup],
|
||||
new_group: PromoGroup,
|
||||
total_spent_kopeks: int,
|
||||
):
|
||||
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
|
||||
return
|
||||
|
||||
bot_token = getattr(settings, "BOT_TOKEN", None)
|
||||
if not bot_token:
|
||||
logger.debug("BOT_TOKEN не настроен — пропускаем уведомление о промогруппе")
|
||||
return
|
||||
|
||||
bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode="HTML"))
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
reason = (
|
||||
f"Автоназначение за траты {settings.format_price(total_spent_kopeks)}"
|
||||
if hasattr(settings, "format_price")
|
||||
else f"Автоназначение за траты {total_spent_kopeks / 100:.2f}₽"
|
||||
)
|
||||
await notification_service.send_user_promo_group_change_notification(
|
||||
db,
|
||||
user,
|
||||
old_group,
|
||||
new_group,
|
||||
reason=reason,
|
||||
initiator=None,
|
||||
automatic=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о автоназначении промогруппы пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
await bot.session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _get_best_group_for_spending(
|
||||
db: AsyncSession,
|
||||
total_spent_kopeks: int,
|
||||
min_threshold_kopeks: int = 0,
|
||||
) -> Optional[PromoGroup]:
|
||||
if total_spent_kopeks <= 0:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
.order_by(PromoGroup.auto_assign_total_spent_kopeks.desc(), PromoGroup.id.desc())
|
||||
)
|
||||
groups = result.scalars().all()
|
||||
|
||||
for group in groups:
|
||||
threshold = group.auto_assign_total_spent_kopeks or 0
|
||||
if (
|
||||
threshold
|
||||
and total_spent_kopeks >= threshold
|
||||
and threshold > min_threshold_kopeks
|
||||
):
|
||||
return group
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def maybe_assign_promo_group_by_total_spent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[PromoGroup]:
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
|
||||
return None
|
||||
|
||||
old_group = None
|
||||
if user.promo_group_id:
|
||||
try:
|
||||
await db.refresh(user, attribute_names=["promo_group"])
|
||||
except Exception:
|
||||
pass
|
||||
old_group = getattr(user, "promo_group", None)
|
||||
|
||||
total_spent = await get_user_total_spent_kopeks(db, user_id)
|
||||
if total_spent <= 0:
|
||||
return None
|
||||
|
||||
previous_threshold = user.auto_promo_group_threshold_kopeks or 0
|
||||
|
||||
target_group = await _get_best_group_for_spending(
|
||||
db,
|
||||
total_spent,
|
||||
min_threshold_kopeks=previous_threshold,
|
||||
)
|
||||
if not target_group:
|
||||
return None
|
||||
|
||||
try:
|
||||
previous_group_id = user.promo_group_id
|
||||
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
|
||||
|
||||
if target_threshold <= previous_threshold:
|
||||
logger.debug(
|
||||
"Порог промогруппы '%s' (%s) не превышает ранее назначенный (%s) для пользователя %s",
|
||||
target_group.name,
|
||||
target_threshold,
|
||||
previous_threshold,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if user.auto_promo_group_assigned and target_group.id == previous_group_id:
|
||||
logger.debug(
|
||||
"Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
if target_threshold > previous_threshold:
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return target_group
|
||||
|
||||
user.auto_promo_group_assigned = True
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
user.promo_group_id = target_group.id
|
||||
user.promo_group = target_group
|
||||
logger.info(
|
||||
"🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s ₽",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
total_spent / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
await _notify_admins_about_auto_assignment(
|
||||
db,
|
||||
user,
|
||||
old_group,
|
||||
target_group,
|
||||
total_spent,
|
||||
)
|
||||
|
||||
return target_group
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Ошибка при автоматическом назначении промогруппы пользователю %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
await db.rollback()
|
||||
return None
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
DiscountOffer,
|
||||
Subscription,
|
||||
SubscriptionTemporaryAccess,
|
||||
User,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PromoOfferService:
|
||||
def __init__(self) -> None:
|
||||
self.subscription_service = SubscriptionService()
|
||||
|
||||
async def grant_test_access(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
offer: DiscountOffer,
|
||||
) -> Tuple[bool, Optional[List[str]], Optional[datetime], str]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription:
|
||||
return False, None, None, "subscription_missing"
|
||||
|
||||
payload = offer.extra_data or {}
|
||||
raw_squads = payload.get("test_squad_uuids") or payload.get("squads") or []
|
||||
if isinstance(raw_squads, str):
|
||||
candidates = [raw_squads]
|
||||
else:
|
||||
try:
|
||||
candidates = list(raw_squads)
|
||||
except TypeError:
|
||||
candidates = []
|
||||
|
||||
squad_uuids: Sequence[str] = [str(item) for item in candidates if item]
|
||||
if not squad_uuids:
|
||||
return False, None, None, "squads_missing"
|
||||
|
||||
squad_uuids = list(dict.fromkeys(squad_uuids))
|
||||
|
||||
connected = {str(item) for item in subscription.connected_squads or []}
|
||||
if squad_uuids and set(squad_uuids).issubset(connected):
|
||||
return False, None, None, "already_connected"
|
||||
|
||||
try:
|
||||
duration_hours = int(payload.get("test_duration_hours") or payload.get("duration_hours") or 24)
|
||||
except (TypeError, ValueError):
|
||||
duration_hours = 24
|
||||
|
||||
if duration_hours <= 0:
|
||||
duration_hours = 24
|
||||
|
||||
now = datetime.utcnow()
|
||||
expires_at = now + timedelta(hours=duration_hours)
|
||||
|
||||
original_connected = set(connected)
|
||||
newly_added: List[str] = []
|
||||
changes_made = False
|
||||
|
||||
for squad_uuid in squad_uuids:
|
||||
normalized_uuid = str(squad_uuid)
|
||||
existing_result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.where(
|
||||
SubscriptionTemporaryAccess.offer_id == offer.id,
|
||||
SubscriptionTemporaryAccess.squad_uuid == normalized_uuid,
|
||||
)
|
||||
.order_by(SubscriptionTemporaryAccess.id.desc())
|
||||
)
|
||||
existing_access = existing_result.scalars().first()
|
||||
if existing_access and existing_access.is_active:
|
||||
if existing_access.expires_at < expires_at:
|
||||
existing_access.expires_at = expires_at
|
||||
changes_made = True
|
||||
continue
|
||||
|
||||
was_already_connected = normalized_uuid in connected
|
||||
if not was_already_connected:
|
||||
connected.add(normalized_uuid)
|
||||
newly_added.append(normalized_uuid)
|
||||
changes_made = True
|
||||
|
||||
access_entry = SubscriptionTemporaryAccess(
|
||||
subscription_id=subscription.id,
|
||||
offer_id=offer.id,
|
||||
squad_uuid=normalized_uuid,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
was_already_connected=was_already_connected,
|
||||
)
|
||||
db.add(access_entry)
|
||||
changes_made = True
|
||||
|
||||
connected_changed = connected != original_connected
|
||||
|
||||
if newly_added:
|
||||
subscription.connected_squads = list(connected)
|
||||
subscription.updated_at = now
|
||||
changes_made = True
|
||||
|
||||
if connected_changed:
|
||||
remnawave_user = await self.subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
)
|
||||
if remnawave_user is None:
|
||||
await db.rollback()
|
||||
await db.refresh(subscription)
|
||||
logger.error(
|
||||
"Не удалось синхронизировать временный доступ подписки %s с RemnaWave",
|
||||
subscription.id,
|
||||
)
|
||||
return False, None, None, "remnawave_sync_failed"
|
||||
|
||||
await db.refresh(subscription)
|
||||
elif changes_made:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
return True, newly_added, expires_at, "ok"
|
||||
|
||||
async def cleanup_expired_test_access(self, db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.options(
|
||||
selectinload(SubscriptionTemporaryAccess.subscription),
|
||||
selectinload(SubscriptionTemporaryAccess.offer),
|
||||
)
|
||||
.where(
|
||||
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
|
||||
SubscriptionTemporaryAccess.expires_at <= now,
|
||||
)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
if not entries:
|
||||
return 0
|
||||
|
||||
subscriptions_updates: dict[int, Tuple[Subscription, set[str]]] = {}
|
||||
log_payloads: List[Dict[str, object]] = []
|
||||
|
||||
for entry in entries:
|
||||
entry.is_active = False
|
||||
entry.deactivated_at = now
|
||||
subscription = entry.subscription
|
||||
if not subscription:
|
||||
continue
|
||||
|
||||
bucket = subscriptions_updates.setdefault(subscription.id, (subscription, set()))
|
||||
if not entry.was_already_connected:
|
||||
bucket[1].add(entry.squad_uuid)
|
||||
|
||||
user_id = subscription.user_id
|
||||
if user_id:
|
||||
offer = entry.offer
|
||||
log_payloads.append(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"offer_id": entry.offer_id,
|
||||
"source": getattr(offer, "notification_type", None),
|
||||
"percent": getattr(offer, "discount_percent", None),
|
||||
"effect_type": getattr(offer, "effect_type", "test_access"),
|
||||
"details": {
|
||||
"reason": "test_access_expired",
|
||||
"squad_uuid": entry.squad_uuid,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for subscription, squads_to_remove in subscriptions_updates.values():
|
||||
if not squads_to_remove:
|
||||
continue
|
||||
current = set(subscription.connected_squads or [])
|
||||
updated = current.difference(squads_to_remove)
|
||||
if updated != current:
|
||||
subscription.connected_squads = list(updated)
|
||||
subscription.updated_at = now
|
||||
try:
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Ошибка обновления Remnawave при отзыве тестового доступа подписки %s: %s",
|
||||
subscription.id,
|
||||
exc,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
for payload in log_payloads:
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=payload["user_id"],
|
||||
offer_id=payload.get("offer_id"),
|
||||
action="disabled",
|
||||
source=payload.get("source"),
|
||||
percent=payload.get("percent"),
|
||||
effect_type=payload.get("effect_type"),
|
||||
details=payload.get("details"),
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer test access disable log for user %s: %s",
|
||||
payload.get("user_id"),
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer test access log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
return len(entries)
|
||||
|
||||
|
||||
promo_offer_service = PromoOfferService()
|
||||
@@ -49,23 +49,34 @@ class PromoCodeService:
|
||||
return {"success": False, "error": "already_used_by_user"}
|
||||
|
||||
result_description = await self._apply_promocode_effects(db, user, promocode)
|
||||
|
||||
|
||||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
await mark_user_as_had_paid_subscription(db, user)
|
||||
|
||||
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
|
||||
|
||||
|
||||
await create_promocode_use(db, promocode.id, user_id)
|
||||
|
||||
|
||||
promocode.current_uses += 1
|
||||
await db.commit()
|
||||
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
|
||||
|
||||
|
||||
promocode_data = {
|
||||
"code": promocode.code,
|
||||
"type": promocode.type,
|
||||
"balance_bonus_kopeks": promocode.balance_bonus_kopeks,
|
||||
"subscription_days": promocode.subscription_days,
|
||||
"max_uses": promocode.max_uses,
|
||||
"current_uses": promocode.current_uses,
|
||||
"valid_until": promocode.valid_until,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"description": result_description
|
||||
"description": result_description,
|
||||
"promocode": promocode_data,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -102,16 +113,32 @@ class PromoCodeService:
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
|
||||
trial_squads = []
|
||||
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось подобрать сквад для подписки по промокоду %s: %s",
|
||||
promocode.code,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=promocode.subscription_days,
|
||||
traffic_limit_gb=0,
|
||||
traffic_limit_gb=0,
|
||||
device_limit=1,
|
||||
connected_squads=trial_squads
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, new_subscription)
|
||||
@@ -129,9 +156,9 @@ class PromoCodeService:
|
||||
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
|
||||
|
||||
trial_subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import logging
|
||||
from html.parser import HTMLParser
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.public_offer import (
|
||||
get_public_offer,
|
||||
set_public_offer_enabled,
|
||||
upsert_public_offer,
|
||||
)
|
||||
from app.database.models import PublicOffer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PublicOfferService:
|
||||
"""Helpers for managing the public offer text and visibility."""
|
||||
|
||||
MAX_PAGE_LENGTH = 3500
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: str) -> str:
|
||||
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
|
||||
return base_language.split("-")[0].lower()
|
||||
|
||||
@staticmethod
|
||||
def normalize_language(language: str) -> str:
|
||||
return PublicOfferService._normalize_language(language)
|
||||
|
||||
@classmethod
|
||||
async def get_offer(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = False,
|
||||
) -> Optional[PublicOffer]:
|
||||
lang = cls._normalize_language(language)
|
||||
offer = await get_public_offer(db, lang)
|
||||
|
||||
if offer or not fallback:
|
||||
return offer
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
return await get_public_offer(db, default_lang)
|
||||
|
||||
return offer
|
||||
|
||||
@classmethod
|
||||
async def get_active_offer(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> Optional[PublicOffer]:
|
||||
lang = cls._normalize_language(language)
|
||||
offer = await get_public_offer(db, lang)
|
||||
|
||||
if offer:
|
||||
if offer.is_enabled and offer.content.strip():
|
||||
return offer
|
||||
|
||||
if not offer.is_enabled:
|
||||
return None
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
fallback_offer = await get_public_offer(db, default_lang)
|
||||
if fallback_offer and fallback_offer.is_enabled and fallback_offer.content.strip():
|
||||
return fallback_offer
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def is_offer_enabled(cls, db: AsyncSession, language: str) -> bool:
|
||||
offer = await cls.get_active_offer(db, language)
|
||||
return offer is not None
|
||||
|
||||
@classmethod
|
||||
async def save_offer(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
) -> PublicOffer:
|
||||
lang = cls._normalize_language(language)
|
||||
enable_if_new = True
|
||||
offer = await upsert_public_offer(
|
||||
db,
|
||||
lang,
|
||||
content,
|
||||
enable_if_new=enable_if_new,
|
||||
)
|
||||
logger.info("✅ Публичная оферта обновлена для языка %s", lang)
|
||||
return offer
|
||||
|
||||
@classmethod
|
||||
async def set_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PublicOffer:
|
||||
lang = cls._normalize_language(language)
|
||||
return await set_public_offer_enabled(db, lang, enabled)
|
||||
|
||||
@classmethod
|
||||
async def toggle_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> PublicOffer:
|
||||
lang = cls._normalize_language(language)
|
||||
offer = await get_public_offer(db, lang)
|
||||
|
||||
if offer:
|
||||
new_status = not offer.is_enabled
|
||||
else:
|
||||
new_status = True
|
||||
|
||||
return await set_public_offer_enabled(db, lang, new_status)
|
||||
|
||||
class _RichTextPaginator(HTMLParser):
|
||||
"""Split HTML-like text into Telegram-safe chunks."""
|
||||
|
||||
SELF_CLOSING_TAGS = {"br", "hr", "img", "input", "meta", "link"}
|
||||
|
||||
def __init__(self, max_len: int) -> None:
|
||||
super().__init__(convert_charrefs=False)
|
||||
self.max_len = max_len
|
||||
self.pages: List[str] = []
|
||||
self.current_parts: List[str] = []
|
||||
self.current_length = 0
|
||||
self.open_stack: List[Tuple[str, str, int]] = []
|
||||
self.closing_length = 0
|
||||
self.needs_prefix = False
|
||||
self.prefix_length = 0
|
||||
|
||||
def _closing_sequence(self) -> str:
|
||||
return "".join(f"</{name}>" for name, _, _ in reversed(self.open_stack))
|
||||
|
||||
def _ensure_prefix(self) -> None:
|
||||
if not self.needs_prefix:
|
||||
return
|
||||
|
||||
prefix = "".join(token for _, token, _ in self.open_stack)
|
||||
if prefix:
|
||||
self.current_parts.append(prefix)
|
||||
self.current_length += len(prefix)
|
||||
self.needs_prefix = False
|
||||
|
||||
def _flush(self) -> None:
|
||||
if not self.current_parts and not self.closing_length:
|
||||
return
|
||||
|
||||
content = "".join(self.current_parts)
|
||||
closing_tags = self._closing_sequence()
|
||||
page = (content + closing_tags).strip()
|
||||
if page:
|
||||
self.pages.append(page)
|
||||
|
||||
self.current_parts = []
|
||||
self.current_length = 0
|
||||
if self.prefix_length + self.closing_length > self.max_len:
|
||||
self.open_stack = []
|
||||
self.closing_length = 0
|
||||
self.prefix_length = 0
|
||||
self.needs_prefix = False
|
||||
else:
|
||||
self.needs_prefix = bool(self.open_stack)
|
||||
|
||||
@staticmethod
|
||||
def _format_attrs(attrs: List[Tuple[str, Optional[str]]]) -> str:
|
||||
parts = []
|
||||
for name, value in attrs:
|
||||
if value is None:
|
||||
parts.append(f" {name}")
|
||||
else:
|
||||
parts.append(f" {name}=\"{value}\"")
|
||||
return "".join(parts)
|
||||
|
||||
def _append_token(self, token: str) -> None:
|
||||
while True:
|
||||
self._ensure_prefix()
|
||||
if self.current_length + len(token) + self.closing_length <= self.max_len:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
break
|
||||
|
||||
self._flush()
|
||||
|
||||
if len(token) > self.max_len and not self.current_parts:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
break
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not data:
|
||||
return
|
||||
|
||||
remaining = data
|
||||
while remaining:
|
||||
self._ensure_prefix()
|
||||
available = self.max_len - (self.current_length + self.closing_length)
|
||||
if available <= 0:
|
||||
self._flush()
|
||||
continue
|
||||
|
||||
piece = remaining[:available]
|
||||
self.current_parts.append(piece)
|
||||
self.current_length += len(piece)
|
||||
remaining = remaining[available:]
|
||||
|
||||
if remaining:
|
||||
self._flush()
|
||||
|
||||
def handle_entityref(self, name: str) -> None:
|
||||
self.handle_data(f"&{name};")
|
||||
|
||||
def handle_charref(self, name: str) -> None:
|
||||
self.handle_data(f"&#{name};")
|
||||
|
||||
def _handle_self_closing(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
token = f"<{tag}{self._format_attrs(attrs)}/>"
|
||||
self._append_token(token)
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
if tag in self.SELF_CLOSING_TAGS:
|
||||
self._handle_self_closing(tag, attrs)
|
||||
return
|
||||
|
||||
token = f"<{tag}{self._format_attrs(attrs)}>"
|
||||
closing_token = f"</{tag}>"
|
||||
closing_len = len(closing_token)
|
||||
|
||||
while True:
|
||||
self._ensure_prefix()
|
||||
projected_length = self.current_length + len(token) + self.closing_length + closing_len
|
||||
if projected_length <= self.max_len:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
self.open_stack.append((tag, token, closing_len))
|
||||
self.closing_length += closing_len
|
||||
self.prefix_length += len(token)
|
||||
break
|
||||
|
||||
self._flush()
|
||||
|
||||
if len(token) + closing_len > self.max_len and not self.current_parts:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
self.open_stack.append((tag, token, closing_len))
|
||||
self.closing_length += closing_len
|
||||
self.prefix_length += len(token)
|
||||
break
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
token = f"</{tag}>"
|
||||
closing_len_reduction = 0
|
||||
index_to_remove = None
|
||||
for index in range(len(self.open_stack) - 1, -1, -1):
|
||||
if self.open_stack[index][0] == tag:
|
||||
closing_len_reduction = self.open_stack[index][2]
|
||||
index_to_remove = index
|
||||
break
|
||||
|
||||
while True:
|
||||
self._ensure_prefix()
|
||||
projected_closing_length = self.closing_length - closing_len_reduction
|
||||
if projected_closing_length < 0:
|
||||
projected_closing_length = 0
|
||||
|
||||
projected_total = self.current_length + len(token) + projected_closing_length
|
||||
if projected_total <= self.max_len or not self.current_parts:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
if index_to_remove is not None:
|
||||
removed_tag = self.open_stack.pop(index_to_remove)
|
||||
self.closing_length -= closing_len_reduction
|
||||
self.prefix_length -= len(removed_tag[1])
|
||||
break
|
||||
|
||||
self._flush()
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
self._handle_self_closing(tag, attrs)
|
||||
|
||||
def finalize(self) -> List[str]:
|
||||
self._flush()
|
||||
return self.pages
|
||||
|
||||
@classmethod
|
||||
def _split_rich_paragraph(cls, paragraph: str, max_len: int) -> List[str]:
|
||||
if len(paragraph) <= max_len:
|
||||
return [paragraph]
|
||||
|
||||
paginator = cls._RichTextPaginator(max_len)
|
||||
paginator.feed(paragraph)
|
||||
paginator.close()
|
||||
pages = paginator.finalize()
|
||||
return pages or [paragraph]
|
||||
|
||||
@classmethod
|
||||
def split_content_into_pages(
|
||||
cls,
|
||||
content: str,
|
||||
*,
|
||||
max_length: int = None,
|
||||
) -> List[str]:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
max_len = max_length or cls.MAX_PAGE_LENGTH
|
||||
|
||||
if len(normalized) <= max_len:
|
||||
return [normalized]
|
||||
|
||||
paragraphs = [
|
||||
paragraph.strip()
|
||||
for paragraph in normalized.split("\n\n")
|
||||
if paragraph.strip()
|
||||
]
|
||||
|
||||
pages: List[str] = []
|
||||
current = ""
|
||||
|
||||
def flush_current() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
pages.append(current.strip())
|
||||
current = ""
|
||||
|
||||
for paragraph in paragraphs:
|
||||
segments = cls._split_rich_paragraph(paragraph, max_len)
|
||||
for segment in segments:
|
||||
segment = segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
|
||||
candidate = f"{current}\n\n{segment}".strip() if current else segment
|
||||
if len(candidate) <= max_len:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
flush_current()
|
||||
current = segment
|
||||
|
||||
flush_current()
|
||||
|
||||
if not pages:
|
||||
return [normalized[:max_len]]
|
||||
|
||||
return pages
|
||||
@@ -1,17 +1,25 @@
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
|
||||
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
|
||||
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, update_subscription_usage
|
||||
from app.database.crud.subscription import (
|
||||
get_subscription_by_user_id,
|
||||
update_subscription_usage,
|
||||
decrement_subscription_server_counts,
|
||||
)
|
||||
from app.database.models import (
|
||||
User, SubscriptionServer, Transaction, ReferralEarning,
|
||||
PromoCodeUse, SubscriptionStatus
|
||||
@@ -20,49 +28,106 @@ from app.database.models import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemnaWaveConfigurationError(Exception):
|
||||
"""Raised when RemnaWave API configuration is missing."""
|
||||
|
||||
|
||||
class RemnaWaveService:
|
||||
|
||||
|
||||
def __init__(self):
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
base_url = (auth_params.get("base_url") or "").strip()
|
||||
api_key = (auth_params.get("api_key") or "").strip()
|
||||
|
||||
self._config_error: Optional[str] = None
|
||||
|
||||
tz_name = os.getenv("TZ", "UTC")
|
||||
try:
|
||||
self._panel_timezone = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s'. Используется UTC.",
|
||||
tz_name,
|
||||
)
|
||||
self._panel_timezone = ZoneInfo("UTC")
|
||||
|
||||
if not base_url:
|
||||
self._config_error = "REMNAWAVE_API_URL не настроен"
|
||||
elif not api_key:
|
||||
self._config_error = "REMNAWAVE_API_KEY не настроен"
|
||||
|
||||
self.api: Optional[RemnaWaveAPI]
|
||||
if self._config_error:
|
||||
self.api = None
|
||||
else:
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=auth_params.get("secret_key"),
|
||||
username=auth_params.get("username"),
|
||||
password=auth_params.get("password")
|
||||
)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self._config_error is None
|
||||
|
||||
@property
|
||||
def configuration_error(self) -> Optional[str]:
|
||||
return self._config_error
|
||||
|
||||
def _ensure_configured(self) -> None:
|
||||
if not self.is_configured or self.api is None:
|
||||
raise RemnaWaveConfigurationError(
|
||||
self._config_error or "RemnaWave API не настроен"
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_api_client(self):
|
||||
self._ensure_configured()
|
||||
assert self.api is not None
|
||||
async with self.api as api:
|
||||
yield api
|
||||
|
||||
def _now_in_panel_timezone(self) -> datetime:
|
||||
"""Возвращает текущее время без часового пояса в зоне панели."""
|
||||
return datetime.now(self._panel_timezone).replace(tzinfo=None)
|
||||
|
||||
def _parse_remnawave_date(self, date_str: str) -> datetime:
|
||||
if not date_str:
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
return self._now_in_panel_timezone() + timedelta(days=30)
|
||||
|
||||
try:
|
||||
|
||||
|
||||
cleaned_date = date_str.strip()
|
||||
|
||||
|
||||
if cleaned_date.endswith('Z'):
|
||||
cleaned_date = cleaned_date[:-1] + '+00:00'
|
||||
|
||||
|
||||
if '+00:00+00:00' in cleaned_date:
|
||||
cleaned_date = cleaned_date.replace('+00:00+00:00', '+00:00')
|
||||
|
||||
|
||||
cleaned_date = re.sub(r'(\+\d{2}:\d{2})\+\d{2}:\d{2}$', r'\1', cleaned_date)
|
||||
|
||||
|
||||
parsed_date = datetime.fromisoformat(cleaned_date)
|
||||
|
||||
|
||||
if parsed_date.tzinfo is not None:
|
||||
parsed_date = parsed_date.replace(tzinfo=None)
|
||||
|
||||
logger.debug(f"Успешно распарсена дата: {date_str} -> {parsed_date}")
|
||||
return parsed_date
|
||||
|
||||
localized = parsed_date.astimezone(self._panel_timezone)
|
||||
else:
|
||||
localized = parsed_date.replace(tzinfo=self._panel_timezone)
|
||||
|
||||
localized_naive = localized.replace(tzinfo=None)
|
||||
|
||||
logger.debug(f"Успешно распарсена дата: {date_str} -> {localized_naive}")
|
||||
return localized_naive
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.")
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
return self._now_in_panel_timezone() + timedelta(days=30)
|
||||
|
||||
async def get_system_statistics(self) -> Dict[str, Any]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
logger.info("Получение системной статистики RemnaWave...")
|
||||
|
||||
try:
|
||||
@@ -255,7 +320,7 @@ class RemnaWaveService:
|
||||
async def get_all_nodes(self) -> List[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
result = []
|
||||
@@ -284,7 +349,7 @@ class RemnaWaveService:
|
||||
async def test_connection(self) -> bool:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
stats = await api.get_system_stats()
|
||||
logger.info("✅ Соединение с Remnawave API работает")
|
||||
return True
|
||||
@@ -295,7 +360,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
node = await api.get_node_by_uuid(node_uuid)
|
||||
|
||||
if not node:
|
||||
@@ -321,7 +386,7 @@ class RemnaWaveService:
|
||||
|
||||
async def manage_node(self, node_uuid: str, action: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
if action == "enable":
|
||||
await api.enable_node(node_uuid)
|
||||
elif action == "disable":
|
||||
@@ -340,7 +405,7 @@ class RemnaWaveService:
|
||||
|
||||
async def restart_all_nodes(self) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
result = await api.restart_all_nodes()
|
||||
|
||||
if result:
|
||||
@@ -354,7 +419,7 @@ class RemnaWaveService:
|
||||
|
||||
async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'inbounds': inbound_uuids
|
||||
@@ -368,7 +433,7 @@ class RemnaWaveService:
|
||||
async def get_all_squads(self) -> List[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
squads = await api.get_internal_squads()
|
||||
|
||||
result = []
|
||||
@@ -390,7 +455,7 @@ class RemnaWaveService:
|
||||
|
||||
async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
squad = await api.create_internal_squad(name, inbounds)
|
||||
|
||||
logger.info(f"✅ Создан новый сквад: {name}")
|
||||
@@ -402,7 +467,7 @@ class RemnaWaveService:
|
||||
|
||||
async def update_squad(self, uuid: str, name: str = None, inbounds: List[str] = None) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
await api.update_internal_squad(uuid, name, inbounds)
|
||||
|
||||
logger.info(f"✅ Обновлен сквад {uuid}")
|
||||
@@ -414,7 +479,7 @@ class RemnaWaveService:
|
||||
|
||||
async def delete_squad(self, uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
result = await api.delete_internal_squad(uuid)
|
||||
|
||||
if result:
|
||||
@@ -432,7 +497,7 @@ class RemnaWaveService:
|
||||
|
||||
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
panel_users = []
|
||||
start = 0
|
||||
size = 100
|
||||
@@ -458,6 +523,7 @@ class RemnaWaveService:
|
||||
'usedTrafficBytes': user_obj.used_traffic_bytes,
|
||||
'hwidDeviceLimit': user_obj.hwid_device_limit,
|
||||
'subscriptionUrl': user_obj.subscription_url,
|
||||
'subscriptionCryptoLink': user_obj.happ_crypto_link,
|
||||
'activeInternalSquads': user_obj.active_internal_squads
|
||||
}
|
||||
panel_users.append(user_dict)
|
||||
@@ -549,7 +615,7 @@ class RemnaWaveService:
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
devices_reset = await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
if devices_reset:
|
||||
logger.info(f"🔧 Сброшены HWID устройства для пользователя {telegram_id}")
|
||||
@@ -559,7 +625,9 @@ class RemnaWaveService:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from app.database.models import SubscriptionServer
|
||||
|
||||
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == subscription.id
|
||||
@@ -581,6 +649,7 @@ class RemnaWaveService:
|
||||
subscription.autopay_enabled = False
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.subscription_url = ""
|
||||
subscription.subscription_crypto_link = ""
|
||||
|
||||
db_user.remnawave_uuid = None
|
||||
|
||||
@@ -610,7 +679,7 @@ class RemnaWaveService:
|
||||
expire_at = self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
panel_status = panel_user.get('status', 'ACTIVE')
|
||||
current_time = datetime.utcnow()
|
||||
current_time = self._now_in_panel_timezone()
|
||||
|
||||
if panel_status == 'ACTIVE' and expire_at > current_time:
|
||||
status = SubscriptionStatus.ACTIVE
|
||||
@@ -637,14 +706,18 @@ class RemnaWaveService:
|
||||
subscription_data = {
|
||||
'user_id': user.id,
|
||||
'status': status.value,
|
||||
'is_trial': False,
|
||||
'is_trial': False,
|
||||
'end_date': expire_at,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'traffic_used_gb': traffic_used_gb,
|
||||
'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1,
|
||||
'connected_squads': squad_uuids,
|
||||
'remnawave_short_uuid': panel_user.get('shortUuid'),
|
||||
'subscription_url': panel_user.get('subscriptionUrl', '')
|
||||
'subscription_url': panel_user.get('subscriptionUrl', ''),
|
||||
'subscription_crypto_link': (
|
||||
panel_user.get('subscriptionCryptoLink')
|
||||
or (panel_user.get('happ') or {}).get('cryptoLink', '')
|
||||
)
|
||||
}
|
||||
|
||||
subscription = await create_subscription(db, **subscription_data)
|
||||
@@ -661,13 +734,17 @@ class RemnaWaveService:
|
||||
user_id=user.id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=False,
|
||||
end_date=datetime.utcnow() + timedelta(days=30),
|
||||
end_date=self._now_in_panel_timezone() + timedelta(days=30),
|
||||
traffic_limit_gb=0,
|
||||
traffic_used_gb=0.0,
|
||||
device_limit=1,
|
||||
connected_squads=[],
|
||||
remnawave_short_uuid=panel_user.get('shortUuid'),
|
||||
subscription_url=panel_user.get('subscriptionUrl', '')
|
||||
subscription_url=panel_user.get('subscriptionUrl', ''),
|
||||
subscription_crypto_link=(
|
||||
panel_user.get('subscriptionCryptoLink')
|
||||
or (panel_user.get('happ') or {}).get('cryptoLink', '')
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}")
|
||||
except Exception as basic_error:
|
||||
@@ -694,7 +771,7 @@ class RemnaWaveService:
|
||||
subscription.end_date = expire_at
|
||||
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
current_time = self._now_in_panel_timezone()
|
||||
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
|
||||
new_status = SubscriptionStatus.ACTIVE.value
|
||||
elif subscription.end_date <= current_time:
|
||||
@@ -733,6 +810,13 @@ class RemnaWaveService:
|
||||
panel_url = panel_user.get('subscriptionUrl', '')
|
||||
if not subscription.subscription_url or subscription.subscription_url != panel_url:
|
||||
subscription.subscription_url = panel_url
|
||||
|
||||
panel_crypto_link = (
|
||||
panel_user.get('subscriptionCryptoLink')
|
||||
or (panel_user.get('happ') or {}).get('cryptoLink', '')
|
||||
)
|
||||
if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link:
|
||||
subscription.subscription_crypto_link = panel_crypto_link
|
||||
|
||||
active_squads = panel_user.get('activeInternalSquads', [])
|
||||
squad_uuids = []
|
||||
@@ -763,7 +847,7 @@ class RemnaWaveService:
|
||||
|
||||
users = await get_users_list(db, offset=0, limit=10000)
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
for user in users:
|
||||
if not user.subscription:
|
||||
continue
|
||||
@@ -825,7 +909,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_user_traffic_stats(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
users = await api.get_user_by_telegram_id(telegram_id)
|
||||
|
||||
if not users:
|
||||
@@ -848,17 +932,23 @@ class RemnaWaveService:
|
||||
return None
|
||||
|
||||
async def test_api_connection(self) -> Dict[str, Any]:
|
||||
if not self.is_configured:
|
||||
return {
|
||||
"status": "not_configured",
|
||||
"message": self.configuration_error or "RemnaWave API не настроен",
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
}
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
system_stats = await api.get_system_stats()
|
||||
|
||||
|
||||
return {
|
||||
"status": "connected",
|
||||
"message": "Подключение успешно",
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
"system_info": system_stats
|
||||
}
|
||||
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -866,6 +956,12 @@ class RemnaWaveService:
|
||||
"status_code": e.status_code,
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
except RemnaWaveConfigurationError as e:
|
||||
return {
|
||||
"status": "not_configured",
|
||||
"message": str(e),
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -875,7 +971,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
usage_data = await api.get_nodes_realtime_usage()
|
||||
return usage_data
|
||||
|
||||
@@ -885,7 +981,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
squad = await api.get_internal_squad_by_uuid(squad_uuid)
|
||||
if squad:
|
||||
return {
|
||||
@@ -902,7 +998,7 @@ class RemnaWaveService:
|
||||
|
||||
async def add_all_users_to_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
@@ -911,25 +1007,16 @@ class RemnaWaveService:
|
||||
|
||||
async def remove_all_users_from_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing users from squad: {e}")
|
||||
return False
|
||||
|
||||
async def delete_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
response = await api.delete_internal_squad(squad_uuid)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting squad: {e}")
|
||||
return False
|
||||
|
||||
async def get_all_inbounds(self) -> List[Dict]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api._make_request('GET', '/api/config-profiles/inbounds')
|
||||
inbounds_data = response.get('response', {}).get('inbounds', [])
|
||||
|
||||
@@ -950,7 +1037,7 @@ class RemnaWaveService:
|
||||
|
||||
async def rename_squad(self, squad_uuid: str, new_name: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'name': new_name
|
||||
@@ -961,18 +1048,9 @@ class RemnaWaveService:
|
||||
logger.error(f"Error renaming squad: {e}")
|
||||
return False
|
||||
|
||||
async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
squad = await api.create_internal_squad(name, inbound_uuids)
|
||||
return squad is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating squad: {e}")
|
||||
return False
|
||||
|
||||
async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
start_str = start_date.isoformat() + "Z"
|
||||
end_str = end_date.isoformat() + "Z"
|
||||
|
||||
@@ -1050,7 +1128,7 @@ class RemnaWaveService:
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
devices_reset = await api.reset_user_devices(user.remnawave_uuid)
|
||||
if devices_reset:
|
||||
logger.info(f"🔧 Сброшены HWID устройства для {user.telegram_id}")
|
||||
@@ -1065,6 +1143,8 @@ class RemnaWaveService:
|
||||
)
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
@@ -1099,21 +1179,22 @@ class RemnaWaveService:
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.used_promocodes = 0
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = self._now_in_panel_timezone()
|
||||
|
||||
if user.subscription:
|
||||
user.subscription.status = SubscriptionStatus.DISABLED.value
|
||||
user.subscription.is_trial = True
|
||||
user.subscription.end_date = datetime.utcnow()
|
||||
user.subscription.end_date = self._now_in_panel_timezone()
|
||||
user.subscription.traffic_limit_gb = 0
|
||||
user.subscription.traffic_used_gb = 0.0
|
||||
user.subscription.device_limit = 1
|
||||
user.subscription.connected_squads = []
|
||||
user.subscription.autopay_enabled = False
|
||||
user.subscription.autopay_days_before = 3
|
||||
user.subscription.autopay_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
user.subscription.remnawave_short_uuid = None
|
||||
user.subscription.subscription_url = ""
|
||||
user.subscription.updated_at = datetime.utcnow()
|
||||
user.subscription.subscription_crypto_link = ""
|
||||
user.subscription.updated_at = self._now_in_panel_timezone()
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1136,7 +1217,7 @@ class RemnaWaveService:
|
||||
|
||||
logger.info("🧹 Начинаем усиленную очистку неактуальных подписок...")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
panel_users_data = await api._make_request('GET', '/api/users')
|
||||
panel_users = panel_users_data['response']['users']
|
||||
|
||||
@@ -1200,7 +1281,7 @@ class RemnaWaveService:
|
||||
|
||||
logger.info("🔄 Начинаем синхронизацию статусов подписок...")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
panel_users_data = await api._make_request('GET', '/api/users')
|
||||
panel_users = panel_users_data['response']['users']
|
||||
|
||||
@@ -1282,7 +1363,7 @@ class RemnaWaveService:
|
||||
user = subscription.user
|
||||
issues_fixed = 0
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
current_time = self._now_in_panel_timezone()
|
||||
if subscription.end_date <= current_time and subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
logger.info(f"🔧 Исправляем статус просроченной подписки {user.telegram_id}")
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
@@ -1290,11 +1371,12 @@ class RemnaWaveService:
|
||||
|
||||
if not subscription.remnawave_short_uuid and user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
rw_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if rw_user:
|
||||
subscription.remnawave_short_uuid = rw_user.short_uuid
|
||||
subscription.subscription_url = rw_user.subscription_url
|
||||
subscription.subscription_crypto_link = rw_user.happ_crypto_link
|
||||
logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}")
|
||||
issues_fixed += 1
|
||||
except Exception as rw_error:
|
||||
@@ -1538,7 +1620,7 @@ class RemnaWaveService:
|
||||
try:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
api_available = True
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time as datetime_time, timedelta, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReportingServiceError(RuntimeError):
|
||||
"""Base error for the reporting service."""
|
||||
|
||||
|
||||
class ReportPeriod(Enum):
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY = "monthly"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReportPeriodRange:
|
||||
start_msk: datetime
|
||||
end_msk: datetime
|
||||
label: str
|
||||
|
||||
|
||||
class ReportingService:
|
||||
"""Generates admin summary reports and can schedule daily delivery."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.bot: Optional[Bot] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._moscow_tz = ZoneInfo("Europe/Moscow")
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
if not settings.ADMIN_REPORTS_ENABLED:
|
||||
logger.info("Сервис отчетов отключен настройками")
|
||||
return
|
||||
|
||||
if not self.bot:
|
||||
logger.warning("Невозможно запустить сервис отчетов без экземпляра бота")
|
||||
return
|
||||
|
||||
chat_id = settings.get_reports_chat_id()
|
||||
if not chat_id:
|
||||
logger.warning("Сервис отчетов не запущен: не указан чат для отправки отчетов")
|
||||
return
|
||||
|
||||
send_time = settings.get_reports_send_time()
|
||||
if not send_time:
|
||||
logger.warning("Сервис отчетов не запущен: не указано время ежедневной отправки")
|
||||
return
|
||||
|
||||
self._task = asyncio.create_task(self._auto_daily_loop(send_time))
|
||||
logger.info(
|
||||
"📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК",
|
||||
send_time.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def send_report(
|
||||
self,
|
||||
period: ReportPeriod,
|
||||
*,
|
||||
report_date: Optional[date] = None,
|
||||
send_to_topic: bool = False,
|
||||
) -> str:
|
||||
report_text = await self._build_report(period, report_date)
|
||||
|
||||
if send_to_topic:
|
||||
await self._deliver_report(report_text)
|
||||
|
||||
return report_text
|
||||
|
||||
async def _auto_daily_loop(self, send_time: datetime_time) -> None:
|
||||
try:
|
||||
next_run_utc, report_date = self._calculate_next_run(send_time)
|
||||
|
||||
while True:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
delay = (next_run_utc - now_utc).total_seconds()
|
||||
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
try:
|
||||
await self.send_report(
|
||||
ReportPeriod.DAILY,
|
||||
report_date=report_date,
|
||||
send_to_topic=True,
|
||||
)
|
||||
logger.info(
|
||||
"📊 Автоматический отчет за %s отправлен",
|
||||
report_date.strftime("%d.%m.%Y"),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка автоматической отправки отчета: %s", exc)
|
||||
|
||||
next_run_utc, report_date = self._calculate_next_run(send_time)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Сервис отчетов остановлен")
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Критическая ошибка в сервисе отчетов: %s", exc)
|
||||
|
||||
def _calculate_next_run(
|
||||
self,
|
||||
send_time: datetime_time,
|
||||
) -> Tuple[datetime, date]:
|
||||
now_msk = datetime.now(self._moscow_tz)
|
||||
candidate = datetime.combine(now_msk.date(), send_time, tzinfo=self._moscow_tz)
|
||||
|
||||
if now_msk >= candidate:
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
report_date = (candidate - timedelta(days=1)).date()
|
||||
return candidate.astimezone(timezone.utc), report_date
|
||||
|
||||
async def _deliver_report(self, report_text: str) -> None:
|
||||
if not self.bot:
|
||||
raise ReportingServiceError("Бот не инициализирован для отправки отчета")
|
||||
|
||||
chat_id = settings.get_reports_chat_id()
|
||||
if not chat_id:
|
||||
raise ReportingServiceError("Не задан чат для отправки отчета")
|
||||
|
||||
topic_id = settings.get_reports_topic_id()
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=report_text,
|
||||
message_thread_id=topic_id,
|
||||
)
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||
logger.error("Не удалось отправить отчет: %s", exc)
|
||||
raise ReportingServiceError("Не удалось отправить отчет в чат") from exc
|
||||
|
||||
async def _build_report(
|
||||
self,
|
||||
period: ReportPeriod,
|
||||
report_date: Optional[date],
|
||||
) -> str:
|
||||
period_range = self._get_period_range(period, report_date)
|
||||
start_utc = period_range.start_msk.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
end_utc = period_range.end_msk.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
totals = await self._collect_current_totals(session)
|
||||
period_stats = await self._collect_period_stats(session, start_utc, end_utc)
|
||||
|
||||
header = (
|
||||
f"📊 <b>Отчет за {period_range.label}</b>"
|
||||
if period == ReportPeriod.DAILY
|
||||
else f"📊 <b>Отчет за период {period_range.label}</b>"
|
||||
)
|
||||
|
||||
lines = [
|
||||
header,
|
||||
"",
|
||||
"🎯 <b>Триалы</b>",
|
||||
f"• Активных сейчас: {totals['active_trials']}",
|
||||
f"• Новых за период: {period_stats['new_trials']}",
|
||||
"",
|
||||
"💎 <b>Платные подписки</b>",
|
||||
f"• Активных сейчас: {totals['active_paid']}",
|
||||
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
|
||||
"",
|
||||
"🎟️ <b>Тикеты поддержки</b>",
|
||||
f"• Активных сейчас: {totals['open_tickets']}",
|
||||
f"• Новых за период: {period_stats['new_tickets']}",
|
||||
"",
|
||||
"💰 <b>Платежи</b>",
|
||||
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
|
||||
f"• Пополнений: {period_stats['deposits_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['deposits_amount'])}",
|
||||
f"• Всего поступлений: {period_stats['total_payments_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['total_payments_amount'])}",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_period_range(
|
||||
self,
|
||||
period: ReportPeriod,
|
||||
report_date: Optional[date],
|
||||
) -> ReportPeriodRange:
|
||||
now_msk = datetime.now(self._moscow_tz)
|
||||
|
||||
if period == ReportPeriod.DAILY:
|
||||
target_date = report_date or (now_msk.date() - timedelta(days=1))
|
||||
start = datetime.combine(target_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
end = start + timedelta(days=1)
|
||||
elif period == ReportPeriod.WEEKLY:
|
||||
end_date = report_date or now_msk.date()
|
||||
start_date = end_date - timedelta(days=7)
|
||||
start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
elif period == ReportPeriod.MONTHLY:
|
||||
end_date = report_date or now_msk.date()
|
||||
start_date = end_date - timedelta(days=30)
|
||||
start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
else: # pragma: no cover - defensive branch
|
||||
raise ReportingServiceError(f"Неизвестный период отчета: {period}")
|
||||
|
||||
label = self._format_period_label(start, end)
|
||||
return ReportPeriodRange(start, end, label)
|
||||
|
||||
async def _collect_current_totals(self, session) -> dict:
|
||||
stats = await get_subscriptions_statistics(session)
|
||||
open_tickets_result = await session.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.status.in_(
|
||||
[
|
||||
TicketStatus.OPEN.value,
|
||||
TicketStatus.ANSWERED.value,
|
||||
TicketStatus.PENDING.value,
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
open_tickets = int(open_tickets_result.scalar() or 0)
|
||||
return {
|
||||
"active_trials": stats.get("trial_subscriptions", 0) or 0,
|
||||
"active_paid": stats.get("paid_subscriptions", 0) or 0,
|
||||
"open_tickets": open_tickets,
|
||||
}
|
||||
|
||||
async def _collect_period_stats(
|
||||
self,
|
||||
session,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
) -> dict:
|
||||
new_trials_result = await session.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.created_at >= start_utc,
|
||||
Subscription.created_at < end_utc,
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
new_trials = int(new_trials_result.scalar() or 0)
|
||||
|
||||
direct_paid_result = await session.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.created_at >= start_utc,
|
||||
Subscription.created_at < end_utc,
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
direct_paid = int(direct_paid_result.scalar() or 0)
|
||||
|
||||
conversions_result = await session.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
SubscriptionConversion.converted_at >= start_utc,
|
||||
SubscriptionConversion.converted_at < end_utc,
|
||||
)
|
||||
)
|
||||
conversions_count = int(conversions_result.scalar() or 0)
|
||||
|
||||
subscription_payments_row = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True, # noqa: E712
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
deposits_row = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True, # noqa: E712
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
subscription_payments_count = int(subscription_payments_row[0] or 0)
|
||||
subscription_payments_amount = int(subscription_payments_row[1] or 0)
|
||||
deposits_count = int(deposits_row[0] or 0)
|
||||
deposits_amount = int(deposits_row[1] or 0)
|
||||
|
||||
total_payments_count = subscription_payments_count + deposits_count
|
||||
total_payments_amount = subscription_payments_amount + deposits_amount
|
||||
new_tickets_result = await session.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.created_at >= start_utc,
|
||||
Ticket.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
new_tickets = int(new_tickets_result.scalar() or 0)
|
||||
|
||||
return {
|
||||
"new_trials": new_trials,
|
||||
"new_paid_subscriptions": direct_paid + conversions_count,
|
||||
"subscription_payments_count": subscription_payments_count,
|
||||
"subscription_payments_amount": subscription_payments_amount,
|
||||
"deposits_count": deposits_count,
|
||||
"deposits_amount": deposits_amount,
|
||||
"total_payments_count": total_payments_count,
|
||||
"total_payments_amount": total_payments_amount,
|
||||
"new_tickets": new_tickets,
|
||||
}
|
||||
|
||||
def _format_period_label(self, start: datetime, end: datetime) -> str:
|
||||
start_date = start.astimezone(self._moscow_tz).date()
|
||||
end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz)
|
||||
end_date = end_boundary.date()
|
||||
|
||||
if start_date == end_date:
|
||||
return start_date.strftime("%d.%m.%Y")
|
||||
|
||||
return (
|
||||
f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
|
||||
)
|
||||
|
||||
def _format_amount(self, amount_kopeks: int) -> str:
|
||||
if not amount_kopeks:
|
||||
return "0 ₽"
|
||||
|
||||
rubles = amount_kopeks / 100
|
||||
return f"{rubles:,.2f} ₽".replace(",", " ")
|
||||
|
||||
|
||||
reporting_service = ReportingService()
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerStatusEntry:
|
||||
address: str
|
||||
instance: Optional[str]
|
||||
protocol: Optional[str]
|
||||
name: str
|
||||
flag: str
|
||||
display_name: str
|
||||
latency_ms: Optional[int]
|
||||
is_online: bool
|
||||
|
||||
|
||||
class ServerStatusError(Exception):
|
||||
"""Raised when server status information cannot be fetched or parsed."""
|
||||
|
||||
|
||||
class ServerStatusService:
|
||||
_LATENCY_PATTERN = re.compile(
|
||||
r"xray_proxy_latency_ms\{(?P<labels>[^}]*)\}\s+(?P<value>[-+]?\d+(?:\.\d+)?)"
|
||||
)
|
||||
_STATUS_PATTERN = re.compile(
|
||||
r"xray_proxy_status\{(?P<labels>[^}]*)\}\s+(?P<value>[-+]?\d+(?:\.\d+)?)"
|
||||
)
|
||||
_LABEL_PATTERN = re.compile(r"(?P<key>[a-zA-Z_][a-zA-Z0-9_]*)=\"(?P<value>(?:\\.|[^\"])*)\"")
|
||||
_FLAG_PATTERN = re.compile(r"^([\U0001F1E6-\U0001F1FF]{2})\s*(.*)$")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
async def get_servers(self) -> List[ServerStatusEntry]:
|
||||
mode = settings.get_server_status_mode()
|
||||
if mode != "xray":
|
||||
raise ServerStatusError("Server status integration is not enabled")
|
||||
|
||||
url = settings.get_server_status_metrics_url()
|
||||
if not url:
|
||||
raise ServerStatusError("Metrics URL is not configured")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=settings.get_server_status_request_timeout())
|
||||
auth = None
|
||||
auth_credentials = settings.get_server_status_metrics_auth()
|
||||
if auth_credentials:
|
||||
username, password = auth_credentials
|
||||
auth = aiohttp.BasicAuth(username, password)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(
|
||||
url,
|
||||
auth=auth,
|
||||
ssl=settings.SERVER_STATUS_METRICS_VERIFY_SSL,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
text = await response.text()
|
||||
raise ServerStatusError(
|
||||
f"Unexpected response status: {response.status}"
|
||||
f" - {text[:200]}"
|
||||
)
|
||||
metrics_body = await response.text()
|
||||
except asyncio.TimeoutError as error:
|
||||
raise ServerStatusError("Request to metrics endpoint timed out") from error
|
||||
except aiohttp.ClientError as error:
|
||||
raise ServerStatusError("Failed to fetch metrics") from error
|
||||
|
||||
return self._parse_metrics(metrics_body)
|
||||
|
||||
def _parse_metrics(self, body: str) -> List[ServerStatusEntry]:
|
||||
servers: Dict[Tuple[str, str, str, str], ServerStatusEntry] = {}
|
||||
|
||||
for match in self._LATENCY_PATTERN.finditer(body):
|
||||
labels = self._parse_labels(match.group("labels"))
|
||||
key = self._build_key(labels)
|
||||
entry = servers.get(key)
|
||||
if not entry:
|
||||
entry = self._create_entry(labels)
|
||||
servers[key] = entry
|
||||
|
||||
try:
|
||||
value = float(match.group("value"))
|
||||
entry.latency_ms = int(round(value))
|
||||
except (TypeError, ValueError):
|
||||
entry.latency_ms = None
|
||||
|
||||
for match in self._STATUS_PATTERN.finditer(body):
|
||||
labels = self._parse_labels(match.group("labels"))
|
||||
key = self._build_key(labels)
|
||||
entry = servers.get(key)
|
||||
if not entry:
|
||||
entry = self._create_entry(labels)
|
||||
servers[key] = entry
|
||||
|
||||
try:
|
||||
value = float(match.group("value"))
|
||||
entry.is_online = value >= 1
|
||||
except (TypeError, ValueError):
|
||||
entry.is_online = False
|
||||
|
||||
return sorted(
|
||||
servers.values(),
|
||||
key=lambda item: (
|
||||
0 if item.is_online else 1,
|
||||
(item.display_name or item.name).lower(),
|
||||
),
|
||||
)
|
||||
|
||||
def _build_key(self, labels: Dict[str, str]) -> Tuple[str, str, str, str]:
|
||||
return (
|
||||
labels.get("address", ""),
|
||||
labels.get("instance", ""),
|
||||
labels.get("protocol", ""),
|
||||
labels.get("name", labels.get("address", "")),
|
||||
)
|
||||
|
||||
def _create_entry(self, labels: Dict[str, str]) -> ServerStatusEntry:
|
||||
name = labels.get("name") or labels.get("address") or "Unknown"
|
||||
flag, display_name = self._extract_flag(name)
|
||||
return ServerStatusEntry(
|
||||
address=labels.get("address", ""),
|
||||
instance=labels.get("instance"),
|
||||
protocol=labels.get("protocol"),
|
||||
name=name,
|
||||
flag=flag,
|
||||
display_name=display_name or name,
|
||||
latency_ms=None,
|
||||
is_online=False,
|
||||
)
|
||||
|
||||
def _extract_flag(self, name: str) -> Tuple[str, str]:
|
||||
match = self._FLAG_PATTERN.match(name)
|
||||
if not match:
|
||||
return "", name
|
||||
flag, remainder = match.groups()
|
||||
return flag, remainder.strip()
|
||||
|
||||
def _parse_labels(self, labels_str: str) -> Dict[str, str]:
|
||||
labels: Dict[str, str] = {}
|
||||
for match in self._LABEL_PATTERN.finditer(labels_str):
|
||||
key = match.group("key")
|
||||
value = match.group("value").replace('\\"', '"')
|
||||
labels[key] = value
|
||||
return labels
|
||||
|
||||
@@ -49,4 +49,10 @@ def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
|
||||
if subscription is None:
|
||||
return True
|
||||
|
||||
return bool(getattr(subscription, "is_trial", False))
|
||||
if getattr(subscription, "is_trial", False):
|
||||
return True
|
||||
|
||||
if getattr(subscription, "actual_status", None) == "expired":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -6,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
@@ -38,6 +39,26 @@ def _resolve_discount_percent(
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_addon_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
group = promo_group or (getattr(user, "promo_group", None) if user else None)
|
||||
|
||||
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
|
||||
return 0
|
||||
|
||||
return _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
category,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
def get_traffic_reset_strategy():
|
||||
from app.config import settings
|
||||
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
|
||||
@@ -55,21 +76,88 @@ def get_traffic_reset_strategy():
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self._config_error: Optional[str] = None
|
||||
self.api: Optional[RemnaWaveAPI] = None
|
||||
self._last_config_signature: Optional[Tuple[str, ...]] = None
|
||||
|
||||
self._refresh_configuration()
|
||||
|
||||
def _refresh_configuration(self) -> None:
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
base_url = (auth_params.get("base_url") or "").strip()
|
||||
api_key = (auth_params.get("api_key") or "").strip()
|
||||
secret_key = (auth_params.get("secret_key") or "").strip() or None
|
||||
username = (auth_params.get("username") or "").strip() or None
|
||||
password = (auth_params.get("password") or "").strip() or None
|
||||
auth_type = (auth_params.get("auth_type") or "").strip() or None
|
||||
|
||||
config_signature = (
|
||||
base_url,
|
||||
api_key,
|
||||
secret_key or "",
|
||||
username or "",
|
||||
password or "",
|
||||
auth_type or "",
|
||||
)
|
||||
|
||||
if config_signature == self._last_config_signature:
|
||||
return
|
||||
|
||||
if not base_url:
|
||||
self._config_error = "REMNAWAVE_API_URL не настроен"
|
||||
self.api = None
|
||||
elif not api_key:
|
||||
self._config_error = "REMNAWAVE_API_KEY не настроен"
|
||||
self.api = None
|
||||
else:
|
||||
self._config_error = None
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=secret_key,
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
if self._config_error:
|
||||
logger.warning(
|
||||
"RemnaWave API недоступен: %s. Подписочный сервис будет работать в оффлайн-режиме.",
|
||||
self._config_error
|
||||
)
|
||||
|
||||
self._last_config_signature = config_signature
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self._config_error is None
|
||||
|
||||
@property
|
||||
def configuration_error(self) -> Optional[str]:
|
||||
return self._config_error
|
||||
|
||||
def _ensure_configured(self) -> None:
|
||||
self._refresh_configuration()
|
||||
if not self.api or not self.is_configured:
|
||||
raise RemnaWaveAPIError(
|
||||
self._config_error or "RemnaWave API не настроен"
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_api_client(self):
|
||||
self._ensure_configured()
|
||||
assert self.api is not None
|
||||
async with self.api as api:
|
||||
yield api
|
||||
|
||||
async def create_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
*,
|
||||
reset_traffic: bool = False,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
@@ -83,7 +171,7 @@ class SubscriptionService:
|
||||
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
|
||||
@@ -110,6 +198,14 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
updated_user.uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
|
||||
username = f"user_{user.telegram_id}"
|
||||
@@ -128,9 +224,18 @@ class SubscriptionService:
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
updated_user.uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
user.remnawave_uuid = updated_user.uuid
|
||||
|
||||
await db.commit()
|
||||
@@ -151,7 +256,10 @@ class SubscriptionService:
|
||||
async def update_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
subscription: Subscription,
|
||||
*,
|
||||
reset_traffic: bool = False,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
@@ -173,7 +281,7 @@ class SubscriptionService:
|
||||
is_actually_active = False
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
updated_user = await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
@@ -189,7 +297,16 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
user.remnawave_uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
await db.commit()
|
||||
|
||||
status_text = "активным" if is_actually_active else "истёкшим"
|
||||
@@ -197,18 +314,39 @@ class SubscriptionService:
|
||||
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
|
||||
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
|
||||
return updated_user
|
||||
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
|
||||
async def _reset_user_traffic(
|
||||
self,
|
||||
api: RemnaWaveAPI,
|
||||
user_uuid: str,
|
||||
telegram_id: int,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
if not user_uuid:
|
||||
return
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
await api.reset_user_traffic(user_uuid)
|
||||
reason_text = f" ({reset_reason})" if reset_reason else ""
|
||||
logger.info(
|
||||
f"🔄 Сброшен трафик RemnaWave для пользователя {telegram_id}{reason_text}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"⚠️ Не удалось сбросить трафик RemnaWave для пользователя {telegram_id}: {exc}"
|
||||
)
|
||||
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
await api.disable_user(user_uuid)
|
||||
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
|
||||
return True
|
||||
@@ -228,11 +366,12 @@ class SubscriptionService:
|
||||
if not user or not user.remnawave_uuid:
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
updated_user = await api.revoke_user_subscription(user.remnawave_uuid)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}")
|
||||
@@ -245,7 +384,7 @@ class SubscriptionService:
|
||||
async def get_subscription_info(self, short_uuid: str) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
info = await api.get_subscription_info(short_uuid)
|
||||
return info
|
||||
|
||||
@@ -264,7 +403,7 @@ class SubscriptionService:
|
||||
if not user or not user.remnawave_uuid:
|
||||
return False
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if not remnawave_user:
|
||||
return False
|
||||
@@ -299,7 +438,15 @@ class SubscriptionService:
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
|
||||
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
@@ -353,7 +500,13 @@ class SubscriptionService:
|
||||
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки:")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if discounted_traffic_price > 0:
|
||||
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
@@ -391,14 +544,16 @@ class SubscriptionService:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group.id if promo_group else None,
|
||||
)
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
@@ -430,6 +585,15 @@ class SubscriptionService:
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
total_price = (
|
||||
base_price
|
||||
+ discounted_servers_price
|
||||
@@ -438,7 +602,13 @@ class SubscriptionService:
|
||||
)
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if servers_price > 0:
|
||||
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽"
|
||||
if servers_discount > 0:
|
||||
@@ -480,7 +650,7 @@ class SubscriptionService:
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
if not remnawave_user:
|
||||
@@ -503,6 +673,7 @@ class SubscriptionService:
|
||||
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.subscription_url = ""
|
||||
subscription.subscription_crypto_link = ""
|
||||
subscription.connected_squads = []
|
||||
|
||||
user.remnawave_uuid = None
|
||||
@@ -518,9 +689,11 @@ class SubscriptionService:
|
||||
return False
|
||||
|
||||
async def get_countries_price_by_uuids(
|
||||
self,
|
||||
country_uuids: List[str],
|
||||
db: AsyncSession
|
||||
self,
|
||||
country_uuids: List[str],
|
||||
db: AsyncSession,
|
||||
*,
|
||||
promo_group_id: Optional[int] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
@@ -530,7 +703,12 @@ class SubscriptionService:
|
||||
|
||||
for country_uuid in country_uuids:
|
||||
server = await get_server_squad_by_uuid(db, country_uuid)
|
||||
if server and server.is_available and not server.is_full:
|
||||
is_allowed = True
|
||||
if promo_group_id is not None and server:
|
||||
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
is_allowed = promo_group_id in allowed_ids
|
||||
|
||||
if server and server.is_available and not server.is_full and is_allowed:
|
||||
price = server.price_kopeks
|
||||
total_price += price
|
||||
prices_list.append(price)
|
||||
@@ -577,7 +755,15 @@ class SubscriptionService:
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
@@ -637,7 +823,13 @@ class SubscriptionService:
|
||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if total_traffic_price > 0:
|
||||
message = (
|
||||
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
@@ -681,14 +873,16 @@ class SubscriptionService:
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group.id if promo_group else None,
|
||||
)
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
@@ -723,10 +917,25 @@ class SubscriptionService:
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
|
||||
@@ -785,7 +994,7 @@ class SubscriptionService:
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
traffic_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
@@ -808,7 +1017,7 @@ class SubscriptionService:
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
devices_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
@@ -835,7 +1044,7 @@ class SubscriptionService:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available:
|
||||
server_price_per_month = server.price_kopeks
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
servers_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SupportSettingsService:
|
||||
"""Runtime editable support settings with JSON persistence."""
|
||||
|
||||
_storage_path: Path = Path("data/support_settings.json")
|
||||
_data: Dict = {}
|
||||
_loaded: bool = False
|
||||
|
||||
@classmethod
|
||||
def _ensure_dir(cls) -> None:
|
||||
try:
|
||||
cls._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure settings dir: {e}")
|
||||
|
||||
@classmethod
|
||||
def _load(cls) -> None:
|
||||
if cls._loaded:
|
||||
return
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
if cls._storage_path.exists():
|
||||
cls._data = json.loads(cls._storage_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
cls._data = {}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load support settings: {e}")
|
||||
cls._data = {}
|
||||
cls._loaded = True
|
||||
|
||||
@classmethod
|
||||
def _save(cls) -> bool:
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
cls._storage_path.write_text(json.dumps(cls._data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save support settings: {e}")
|
||||
return False
|
||||
|
||||
# Mode
|
||||
@classmethod
|
||||
def get_system_mode(cls) -> str:
|
||||
cls._load()
|
||||
mode = (cls._data.get("system_mode") or settings.get_support_system_mode()).strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
@classmethod
|
||||
def set_system_mode(cls, mode: str) -> bool:
|
||||
mode_clean = (mode or "").strip().lower()
|
||||
if mode_clean not in {"tickets", "contact", "both"}:
|
||||
return False
|
||||
cls._load()
|
||||
cls._data["system_mode"] = mode_clean
|
||||
return cls._save()
|
||||
|
||||
# Main menu visibility
|
||||
@classmethod
|
||||
def is_support_menu_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "menu_enabled" in cls._data:
|
||||
return bool(cls._data["menu_enabled"])
|
||||
return bool(settings.SUPPORT_MENU_ENABLED)
|
||||
|
||||
@classmethod
|
||||
def set_support_menu_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["menu_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
# Contact vs tickets helpers
|
||||
@classmethod
|
||||
def is_tickets_enabled(cls) -> bool:
|
||||
return cls.get_system_mode() in {"tickets", "both"}
|
||||
|
||||
@classmethod
|
||||
def is_contact_enabled(cls) -> bool:
|
||||
return cls.get_system_mode() in {"contact", "both"}
|
||||
|
||||
# Descriptions (per language)
|
||||
@classmethod
|
||||
def get_support_info_text(cls, language: str) -> str:
|
||||
cls._load()
|
||||
lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
overrides = cls._data.get("support_info_texts") or {}
|
||||
text = overrides.get(lang)
|
||||
if text and isinstance(text, str) and text.strip():
|
||||
return text
|
||||
# Fallback to dynamic localization default
|
||||
from app.localization.texts import get_texts
|
||||
return get_texts(lang).SUPPORT_INFO
|
||||
|
||||
@classmethod
|
||||
def set_support_info_text(cls, language: str, text: str) -> bool:
|
||||
cls._load()
|
||||
lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
texts_map = cls._data.get("support_info_texts") or {}
|
||||
texts_map[lang] = text or ""
|
||||
cls._data["support_info_texts"] = texts_map
|
||||
return cls._save()
|
||||
|
||||
|
||||
# Notifications & SLA
|
||||
@classmethod
|
||||
def get_admin_ticket_notifications_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "admin_ticket_notifications_enabled" in cls._data:
|
||||
return bool(cls._data["admin_ticket_notifications_enabled"])
|
||||
# fallback to global admin notifications setting
|
||||
return bool(settings.is_admin_notifications_enabled())
|
||||
|
||||
@classmethod
|
||||
def set_admin_ticket_notifications_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["admin_ticket_notifications_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def get_user_ticket_notifications_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "user_ticket_notifications_enabled" in cls._data:
|
||||
return bool(cls._data["user_ticket_notifications_enabled"])
|
||||
# fallback to global enable notifications
|
||||
return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True))
|
||||
|
||||
@classmethod
|
||||
def set_user_ticket_notifications_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["user_ticket_notifications_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def get_sla_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "ticket_sla_enabled" in cls._data:
|
||||
return bool(cls._data["ticket_sla_enabled"])
|
||||
return bool(getattr(settings, "SUPPORT_TICKET_SLA_ENABLED", True))
|
||||
|
||||
@classmethod
|
||||
def set_sla_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["ticket_sla_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def get_sla_minutes(cls) -> int:
|
||||
cls._load()
|
||||
minutes = cls._data.get("ticket_sla_minutes")
|
||||
if isinstance(minutes, int) and minutes > 0:
|
||||
return minutes
|
||||
return int(getattr(settings, "SUPPORT_TICKET_SLA_MINUTES", 5))
|
||||
|
||||
@classmethod
|
||||
def set_sla_minutes(cls, minutes: int) -> bool:
|
||||
try:
|
||||
minutes_int = int(minutes)
|
||||
except Exception:
|
||||
return False
|
||||
if minutes_int <= 0:
|
||||
return False
|
||||
cls._load()
|
||||
cls._data["ticket_sla_minutes"] = minutes_int
|
||||
return cls._save()
|
||||
|
||||
# Moderators management
|
||||
@classmethod
|
||||
def get_moderators(cls) -> list[int]:
|
||||
cls._load()
|
||||
raw = cls._data.get("moderators") or []
|
||||
moderators: list[int] = []
|
||||
for item in raw:
|
||||
try:
|
||||
moderators.append(int(item))
|
||||
except Exception:
|
||||
continue
|
||||
return moderators
|
||||
|
||||
@classmethod
|
||||
def is_moderator(cls, telegram_id: int) -> bool:
|
||||
try:
|
||||
tid = int(telegram_id)
|
||||
except Exception:
|
||||
return False
|
||||
return tid in cls.get_moderators()
|
||||
|
||||
@classmethod
|
||||
def add_moderator(cls, telegram_id: int) -> bool:
|
||||
try:
|
||||
tid = int(telegram_id)
|
||||
except Exception:
|
||||
return False
|
||||
cls._load()
|
||||
moderators = set(cls.get_moderators())
|
||||
moderators.add(tid)
|
||||
cls._data["moderators"] = sorted(moderators)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def remove_moderator(cls, telegram_id: int) -> bool:
|
||||
try:
|
||||
tid = int(telegram_id)
|
||||
except Exception:
|
||||
return False
|
||||
cls._load()
|
||||
moderators = set(cls.get_moderators())
|
||||
if tid in moderators:
|
||||
moderators.remove(tid)
|
||||
cls._data["moderators"] = sorted(moderators)
|
||||
return cls._save()
|
||||
return True
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user