Compare commits
649 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 02a9ac9be5 | |||
| 23f53dc5df | |||
| b4d857eb88 | |||
| 51ba5b980c | |||
| 9fb6d7ecf6 | |||
| d297e9b6d1 |
@@ -147,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
|
||||
|
||||
@@ -161,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)
|
||||
@@ -211,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
|
||||
|
||||
@@ -290,6 +293,10 @@ 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
|
||||
|
||||
@@ -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.4.1-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.6-$(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.4.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.6-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.4.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.6-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.4.1-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.6-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.6-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.6-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!install_bot.sh
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.4.1"
|
||||
ARG VERSION="v2.4.6"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8081 8082
|
||||
EXPOSE 8080 8081 8082
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
|
||||
@@ -71,7 +71,53 @@ eGames Cookies | Cookies в формате key:value | Для панелей eGa
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 🐳 Docker запуск
|
||||
### 🧙♂️ Автоустановка через `install_bot.sh`
|
||||
|
||||
Скрипт-установщик берёт на себя подготовку окружения, настройку конфигурации и дальнейшее обслуживание бота. Он работает поверх Docker Compose и требует заранее установить:
|
||||
|
||||
- **Docker Engine** и **Docker Compose plugin** (2.20+);
|
||||
- **Git** и **Bash** (по умолчанию есть в большинстве Linux дистрибутивов);
|
||||
- `openssl` (используется для генерации токенов, но не обязателен — при отсутствии скрипт использует `urandom`).
|
||||
|
||||
```bash
|
||||
# 1. Скачай репозиторий
|
||||
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
|
||||
# 2. Создай необходимые директории
|
||||
mkdir -p ./logs ./data ./data/backups ./data/referral_qr
|
||||
chmod -R 755 ./logs ./data
|
||||
sudo chown -R 1000:1000 ./logs ./data
|
||||
|
||||
# 3. Запусти мастер установки
|
||||
chmod +x install_bot.sh # один раз, если файл не исполняемый
|
||||
./install_bot.sh
|
||||
```
|
||||
|
||||
На первом запуске мастер:
|
||||
|
||||
1. Спросит путь установки и сохранит его в `./.bot_install_state` — можно оставлять путь по умолчанию (текущая директория).
|
||||
2. Поможет собрать `.env`: запросит обязательные токены (бот, Remnawave, админы), при необходимости сгенерирует Web API и PostgreSQL пароли, предложит авторизацию Basic Auth или eGames secret.
|
||||
3. Подготовит структуру каталогов (`logs`, `data`, `backups` и т. д.) и проверит, что Docker готов к запуску.
|
||||
4. Создаст (или обновит) `docker-compose.yml`, настроит внешнюю сеть `bot_network`, чтобы дальнейшие сервисы (например, Caddy) могли подключаться.
|
||||
5. Запустит контейнеры бота, PostgreSQL и Redis и выведет их статус.
|
||||
|
||||
После установки повторный запуск `./install_bot.sh` открывает **интерактивное меню управления**:
|
||||
|
||||
- 📊 Мониторинг состояния контейнеров и ресурсов.
|
||||
- ⚙️ Управление сервисами (запуск/остановка/пересборка).
|
||||
- 📋 Просмотр и поиск по логам.
|
||||
- 🔄 Обновление проекта из Git с автоматическим бэкапом.
|
||||
- 💾 Создание и 📦 восстановление резервных копий (включая базу данных).
|
||||
- 🧹 Очистка логов, бэкапов и образов.
|
||||
- 🌐 Помощник настройки обратного прокси Caddy (webhook + miniapp, обновление `docker-compose`, перезагрузка).
|
||||
- ⚙️ Конфигуратор `.env` (редактирование, пересоздание, маскировка секретов).
|
||||
|
||||
> 💡 Скрипт можно запускать сколько угодно раз — он хранит путь установки и понимает, когда конфигурация уже создана. Меню работает и по SSH (достаточно TTY), а для скриптов можно передать путь установки через stdin.
|
||||
|
||||
### 🐳 Ручной Docker запуск
|
||||
|
||||
Если не хочется пользоваться мастером, можно настроить всё вручную:
|
||||
|
||||
```bash
|
||||
# 1. Скачай репозиторий
|
||||
@@ -91,11 +137,269 @@ sudo chown -R 1000:1000 ./logs ./data
|
||||
docker compose up -d
|
||||
|
||||
# 5. Проверь статус
|
||||
docker compose logs
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Настройка обратного прокси и доменов
|
||||
|
||||
> Этот раздел описывает полноценную ручную настройку обратного прокси для **двух разных доменов**: отдельный домен для вебхуков
|
||||
> (`hooks.example.com`) и отдельный домен для мини-приложения (`miniapp.example.com`). Оба прокси-сервера (Caddy или nginx) дол
|
||||
> жны работать в одной Docker-сети с ботом, чтобы обращаться к сервису по внутреннему имени `remnawave_bot` без проброса порто
|
||||
> в наружу.
|
||||
|
||||
### 1. Планирование доменов и переменных окружения
|
||||
|
||||
1. Добавьте в DNS по **A/AAAA-записи** для обоих доменов на IP сервера, где запущен бот.
|
||||
2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол).
|
||||
3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами:
|
||||
```env
|
||||
WEBHOOK_URL=https://hooks.example.com
|
||||
WEB_API_ENABLED=true
|
||||
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
|
||||
MINIAPP_CUSTOM_URL=https://miniapp.example.com
|
||||
```
|
||||
При необходимости добавьте пути для дополнительных провайдеров (`TRIBUTE_WEBHOOK_PATH`, `YOOKASSA_WEBHOOK_PATH` и т. д.) —
|
||||
далее в конфигурациях мы их пробрасываем на соответствующие порты контейнера бота.
|
||||
|
||||
### 2. Общая Docker-сеть для бота и прокси
|
||||
|
||||
`docker-compose.yml` бота создаёт сеть `bot_network`. Чтобы внешний прокси видел сервис `remnawave_bot`, нужно:
|
||||
|
||||
```bash
|
||||
# Если бот уже запущен через docker compose up -d
|
||||
docker compose ps
|
||||
|
||||
# Убедиться, что сеть существует
|
||||
docker network ls | grep bot_network || docker network create bot_network
|
||||
|
||||
# Подключить прокси (если контейнер уже запущен отдельно)
|
||||
docker network connect bot_network <proxy_container_name>
|
||||
```
|
||||
|
||||
Если прокси запускается через **собственный docker-compose**, в файле нужно объявить ту же сеть как внешнюю:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
|
||||
Именно так поступает мастер `install_bot.sh`: при первичной установке он создаёт `bot_network` и при повторном запуске в меню №
|
||||
8 («🌐 Настройка обратного прокси (Caddy)») может автоматически подготовить `docker-compose` и `Caddyfile`, обновить сеть и пер
|
||||
езапустить сервис.
|
||||
|
||||
### 3. Ручная установка Caddy в Docker
|
||||
|
||||
1. Создайте каталог для конфигурации:
|
||||
```bash
|
||||
mkdir -p ~/caddy
|
||||
cd ~/caddy
|
||||
```
|
||||
2. Сохраните docker-compose-файл `docker-compose.caddy.yml`:
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: remnawave_caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
# Укажите путь к каталогу miniapp из репозитория бота
|
||||
- /root/remnawave-bedolaga-telegram-bot/miniapp:/miniapp:ro
|
||||
- /root/remnawave-bedolaga-telegram-bot/miniapp/redirect:/miniapp/redirect:ro
|
||||
networks:
|
||||
- bot_network
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
3. Создайте `Caddyfile` с двумя виртуальными хостами:
|
||||
```caddy
|
||||
webhook.domain.com {
|
||||
handle /tribute-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /cryptobot-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /mulenpay-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /pal24-webhook* {
|
||||
reverse_proxy localhost:8084
|
||||
}
|
||||
|
||||
handle /yookassa-webhook* {
|
||||
reverse_proxy localhost:8082
|
||||
}
|
||||
|
||||
handle /health {
|
||||
reverse_proxy localhost:8081/health
|
||||
}
|
||||
}
|
||||
|
||||
miniapp.domain.com {
|
||||
encode gzip zstd
|
||||
root * /var/www/remnawave-miniapp
|
||||
file_server
|
||||
|
||||
@config path /app-config.json
|
||||
header @config Access-Control-Allow-Origin "*"
|
||||
|
||||
# Redirect for /miniapp/redirect/index.html
|
||||
@redirect path /miniapp/redirect/index.html
|
||||
redir @redirect /miniapp/redirect/index.html permanent
|
||||
|
||||
reverse_proxy /miniapp/* 127.0.0.1:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
```
|
||||
- Каталог `miniapp` из репозитория должен быть смонтирован в контейнер Caddy по путям `/miniapp` и `/miniapp/redirect`,
|
||||
чтобы статические файлы мини-приложения и страницы редиректа отдавались без дополнительной сборки. Замените
|
||||
`/root/remnawave-bedolaga-telegram-bot` в примере на реальный путь до установленного репозитория.
|
||||
- Caddy автоматически выпустит TLS-сертификаты через ACME, если DNS настроен правильно.
|
||||
4. Запустите прокси и убедитесь, что он присоединился к сети бота:
|
||||
```bash
|
||||
docker compose -f docker-compose.caddy.yml up -d
|
||||
docker network inspect bot_network | grep remnawave_caddy
|
||||
```
|
||||
5. Проверьте журналы Caddy и бота, выполните тестовый запрос `curl https://hooks.example.com/webhook` (должен отвечать 405/200
|
||||
в зависимости от webhook).
|
||||
|
||||
### 4. Ручная настройка nginx в Docker
|
||||
|
||||
1. Создайте каталог `/opt/nginx-remnawave` и поместите туда `docker-compose.nginx.yml`:
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
container_name: remnawave_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certs:/etc/ssl/private:ro
|
||||
- ./miniapp:/var/www/remnawave-miniapp:ro
|
||||
networks:
|
||||
- bot_network
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
Сертификаты можно выпускать вручную (certbot) и складывать в `./certs` либо использовать внешний контейнер `certbot`.
|
||||
2. Пример `nginx.conf` с двумя серверными блоками:
|
||||
```nginx
|
||||
events {}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
upstream remnawave_bot_hooks {
|
||||
server remnawave_bot:8081;
|
||||
}
|
||||
|
||||
upstream remnawave_bot_yookassa {
|
||||
server remnawave_bot:8082;
|
||||
}
|
||||
|
||||
upstream remnawave_bot_api {
|
||||
server remnawave_bot:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name hooks.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
|
||||
|
||||
location = /webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /tribute-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /cryptobot-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /mulenpay-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /pal24-webhook { proxy_pass http://remnawave_bot:8084; }
|
||||
location /yookassa-webhook { proxy_pass http://remnawave_bot_yookassa; }
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name miniapp.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
|
||||
|
||||
root /var/www/remnawave-miniapp;
|
||||
index index.html;
|
||||
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
location /miniapp/ {
|
||||
proxy_pass http://remnawave_bot_api/miniapp/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-API-Key "КЛЮЧ-WEBAPI";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
3. Запустите прокси и убедитесь, что он в сети `bot_network`:
|
||||
```bash
|
||||
docker compose -f docker-compose.nginx.yml up -d
|
||||
docker network inspect bot_network | grep remnawave_nginx
|
||||
```
|
||||
4. После запуска перепроверьте, что `curl -I https://hooks.example.com/webhook` возвращает HTTP-ответ 405/200, а `curl -H "X-AP
|
||||
I-Key: ..." https://miniapp.example.com/miniapp/health` отвечает 200.
|
||||
|
||||
### 5. Чек-лист проверки
|
||||
|
||||
- [ ] DNS-записи указывают на сервер, сертификаты валидны.
|
||||
- [ ] Контейнер прокси присоединён к `bot_network` и видит сервис `remnawave_bot`.
|
||||
- [ ] `WEBHOOK_URL` и `MINIAPP_CUSTOM_URL` в `.env` указывают на HTTPS-домены.
|
||||
- [ ] В логах бота нет ошибок `connection refused` или `invalid host header` при обращении из прокси.
|
||||
- [ ] Триггеры оплаты (Tribute, YooKassa и др.) успешно получают HTTP 200 при тестовых запросах на свои вебхуки.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Конфигурация
|
||||
|
||||
### 🔧 Основные параметры
|
||||
@@ -112,6 +416,10 @@ docker compose logs
|
||||
|
||||
Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md).
|
||||
|
||||
### 📱 Telegram Mini App с подпиской
|
||||
|
||||
Инструкция по развёртыванию мини-приложения, публикации статической страницы и настройке reverse-proxy доступна в [docs/miniapp-setup.md](docs/miniapp-setup.md).
|
||||
|
||||
### 📊 Статус серверов в главном меню
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
@@ -388,6 +696,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
|
||||
|
||||
@@ -402,6 +711,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)
|
||||
@@ -452,6 +762,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
|
||||
|
||||
@@ -531,6 +842,10 @@ 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
|
||||
|
||||
+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": []
|
||||
}
|
||||
}
|
||||
+12
@@ -33,13 +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
|
||||
|
||||
@@ -135,14 +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 платежей")
|
||||
|
||||
+121
-14
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -38,7 +40,7 @@ class Settings(BaseSettings):
|
||||
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
|
||||
@@ -53,8 +55,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
|
||||
@@ -69,7 +71,7 @@ class Settings(BaseSettings):
|
||||
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
|
||||
@@ -130,8 +132,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
|
||||
@@ -149,6 +152,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
|
||||
@@ -158,9 +162,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
|
||||
@@ -208,9 +213,16 @@ class Settings(BaseSettings):
|
||||
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
|
||||
@@ -267,6 +279,9 @@ class Settings(BaseSettings):
|
||||
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:
|
||||
@@ -399,6 +414,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'"""
|
||||
@@ -442,6 +465,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:
|
||||
@@ -508,9 +540,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"
|
||||
@@ -1018,15 +1109,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}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
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,
|
||||
@@ -16,6 +23,8 @@ async def upsert_discount_offer(
|
||||
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."""
|
||||
|
||||
@@ -37,6 +46,8 @@ async def upsert_discount_offer(
|
||||
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,
|
||||
@@ -46,6 +57,8 @@ async def upsert_discount_offer(
|
||||
bonus_amount_kopeks=bonus_amount_kopeks,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
effect_type=effect_type,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
@@ -56,16 +69,103 @@ async def upsert_discount_offer(
|
||||
|
||||
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(DiscountOffer.id == offer_id)
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.where(DiscountOffer.id == offer_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
|
||||
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 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
|
||||
|
||||
|
||||
@@ -82,9 +182,69 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
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()
|
||||
@@ -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,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
|
||||
@@ -50,6 +50,25 @@ async def get_promo_groups_with_counts(
|
||||
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)
|
||||
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
@@ -38,6 +39,7 @@ async def create_server_squad(
|
||||
description: str = None,
|
||||
max_users: int = None,
|
||||
is_available: bool = True,
|
||||
is_trial_eligible: bool = False,
|
||||
promo_group_ids: Optional[Iterable[int]] = None,
|
||||
) -> ServerSquad:
|
||||
|
||||
@@ -70,6 +72,7 @@ async def create_server_squad(
|
||||
description=description,
|
||||
max_users=max_users,
|
||||
is_available=is_available,
|
||||
is_trial_eligible=is_trial_eligible,
|
||||
allowed_promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
@@ -199,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}
|
||||
@@ -393,8 +396,70 @@ async def get_server_connected_users(
|
||||
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,7 +193,14 @@ 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:
|
||||
@@ -133,7 +223,7 @@ async def extend_subscription(
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value and days > 0:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
|
||||
@@ -223,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,
|
||||
@@ -986,7 +1134,9 @@ async def create_subscription(
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = ""
|
||||
subscription_crypto_link: str = "",
|
||||
autopay_enabled: Optional[bool] = None,
|
||||
autopay_days_before: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
|
||||
if end_date is None:
|
||||
@@ -1006,7 +1156,17 @@ async def create_subscription(
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link
|
||||
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)
|
||||
|
||||
+429
-44
@@ -2,15 +2,17 @@ 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,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
PaymentMethod,
|
||||
@@ -18,6 +20,8 @@ from app.database.models import (
|
||||
)
|
||||
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__)
|
||||
@@ -85,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,
|
||||
@@ -99,46 +134,61 @@ async def create_user(
|
||||
if not referral_code:
|
||||
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)
|
||||
|
||||
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(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(
|
||||
@@ -225,6 +275,8 @@ async def subtract_user_balance(
|
||||
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})")
|
||||
@@ -232,15 +284,57 @@ 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)
|
||||
|
||||
@@ -258,6 +352,32 @@ async def subtract_user_balance(
|
||||
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
|
||||
|
||||
@@ -267,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))
|
||||
@@ -293,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())
|
||||
|
||||
@@ -334,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)
|
||||
@@ -347,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)
|
||||
|
||||
|
||||
+167
-6
@@ -79,6 +79,17 @@ class PaymentMethod(Enum):
|
||||
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"
|
||||
|
||||
@@ -386,9 +397,13 @@ class User(Base):
|
||||
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)
|
||||
@@ -453,6 +468,7 @@ class Subscription(Base):
|
||||
|
||||
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:
|
||||
@@ -571,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:
|
||||
@@ -740,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)
|
||||
|
||||
@@ -756,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)
|
||||
@@ -812,11 +872,73 @@ class DiscountOffer(Base):
|
||||
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"
|
||||
@@ -852,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)
|
||||
|
||||
@@ -1141,4 +1264,42 @@ class WebApiToken(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "revoked"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
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}>"
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
-1
@@ -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
|
||||
|
||||
Vendored
+128
-28
@@ -1,8 +1,9 @@
|
||||
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
|
||||
@@ -65,20 +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(
|
||||
f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
|
||||
"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}")
|
||||
@@ -111,14 +129,18 @@ class WebhookServer:
|
||||
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):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
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'))
|
||||
@@ -128,22 +150,36 @@ class WebhookServer:
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
async for db in get_db():
|
||||
# Получаем соединение с БД
|
||||
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:
|
||||
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:
|
||||
break
|
||||
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
|
||||
@@ -151,28 +187,92 @@ class WebhookServer:
|
||||
logger.error("Mulen Pay secret key is not configured")
|
||||
return False
|
||||
|
||||
signature = request.headers.get('X-MulenPay-Signature')
|
||||
# Логируем все заголовки для отладки
|
||||
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:
|
||||
expected_signature = hmac.new(
|
||||
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,
|
||||
).hexdigest()
|
||||
).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()
|
||||
|
||||
if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()):
|
||||
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 and authorization_header.startswith('Bearer '):
|
||||
token = authorization_header.split(' ', 1)[1].strip()
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
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
|
||||
|
||||
logger.error("Неверный Bearer токен Mulen Pay webhook")
|
||||
return False
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
+1248
-120
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+40
-30
@@ -71,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"
|
||||
)
|
||||
@@ -89,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"
|
||||
)
|
||||
@@ -107,10 +107,10 @@ 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"
|
||||
)
|
||||
@@ -128,16 +128,19 @@ async def show_support_submenu(
|
||||
# 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))
|
||||
|
||||
from app.keyboards.admin import get_admin_support_submenu_keyboard
|
||||
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="🎫 Тикеты поддержки", callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
|
||||
[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(
|
||||
"🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"),
|
||||
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"
|
||||
)
|
||||
@@ -150,12 +153,14 @@ async def show_moderator_panel(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")]
|
||||
[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(
|
||||
"🧑⚖️ <b>Модерация поддержки</b>\n\nДоступ к тикетам поддержки.",
|
||||
texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑⚖️ <b>Модерация поддержки</b>") + "\n\n" +
|
||||
texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."),
|
||||
parse_mode="HTML",
|
||||
reply_markup=kb
|
||||
)
|
||||
@@ -169,6 +174,7 @@ async def show_support_audit(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
# pagination
|
||||
page = 1
|
||||
if callback.data.startswith("admin_support_audit_page_"):
|
||||
@@ -186,18 +192,22 @@ async def show_support_audit(
|
||||
offset = (page - 1) * per_page
|
||||
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
|
||||
|
||||
lines = ["🧾 <b>Аудит модераторов</b>", ""]
|
||||
lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 <b>Аудит модераторов</b>"), ""]
|
||||
if not logs:
|
||||
lines.append("Пока пусто")
|
||||
lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто"))
|
||||
else:
|
||||
for log in logs:
|
||||
role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ"
|
||||
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': 'Закрытие тикета',
|
||||
'block_user_timed': 'Блокировка (время)',
|
||||
'block_user_perm': 'Блокировка (навсегда)',
|
||||
'unblock_user': 'Снятие блока',
|
||||
'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 ""
|
||||
@@ -219,7 +229,7 @@ async def show_support_audit(
|
||||
kb_rows = []
|
||||
if nav_row:
|
||||
kb_rows.append(nav_row)
|
||||
kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")])
|
||||
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)
|
||||
@@ -234,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"
|
||||
)
|
||||
@@ -252,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"
|
||||
)
|
||||
|
||||
@@ -21,8 +21,8 @@ from app.keyboards.admin import (
|
||||
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
|
||||
@@ -31,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:
|
||||
@@ -45,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"],
|
||||
@@ -628,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:
|
||||
@@ -745,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:
|
||||
|
||||
@@ -37,6 +37,9 @@ def _build_notification_settings_view(language: str):
|
||||
|
||||
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))
|
||||
@@ -45,6 +48,7 @@ def _build_notification_settings_view(language: str):
|
||||
"🔔 <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}"
|
||||
@@ -57,6 +61,8 @@ def _build_notification_settings_view(language: str):
|
||||
[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")],
|
||||
@@ -153,6 +159,36 @@ def _build_notification_preview_message(language: str, notification_type: str):
|
||||
],
|
||||
]
|
||||
)
|
||||
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",
|
||||
@@ -190,18 +226,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
|
||||
elif notification_type == "expired_2d":
|
||||
percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
|
||||
(
|
||||
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
|
||||
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
|
||||
"Предложение действует до {expires_at}."
|
||||
"Активируйте предложение, чтобы получить дополнительную скидку. "
|
||||
"Она суммируется с вашей промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
bonus=settings.format_price(bonus_amount),
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
|
||||
trigger_days=3,
|
||||
)
|
||||
@@ -237,18 +271,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
|
||||
percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
|
||||
(
|
||||
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
|
||||
"Скидка действует до {expires_at}."
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
|
||||
"Она суммируется с промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
bonus=settings.format_price(bonus_amount),
|
||||
trigger_days=trigger_days,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
@@ -368,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
|
||||
|
||||
@@ -461,6 +494,27 @@ async def preview_trial_24h_notification(callback: CallbackQuery):
|
||||
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):
|
||||
@@ -533,6 +587,7 @@ async def preview_all_notifications(callback: CallbackQuery):
|
||||
for notification_type in [
|
||||
"trial_inactive_1h",
|
||||
"trial_inactive_24h",
|
||||
"trial_channel_unsubscribed",
|
||||
"expired_1d",
|
||||
"expired_2d",
|
||||
"expired_nd",
|
||||
|
||||
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
@@ -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,
|
||||
)
|
||||
@@ -35,6 +35,8 @@ def _build_server_edit_view(server):
|
||||
else "Не выбраны"
|
||||
)
|
||||
|
||||
trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
@@ -51,6 +53,7 @@ def _build_server_edit_view(server):
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
• Промогруппы: {promo_groups_text}
|
||||
• Выдача триала: {trial_status}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
@@ -80,6 +83,12 @@ def _build_server_edit_view(server):
|
||||
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}"
|
||||
@@ -171,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")
|
||||
]
|
||||
]
|
||||
|
||||
@@ -527,6 +536,38 @@ async def toggle_server_availability(
|
||||
)
|
||||
|
||||
|
||||
@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"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_price(
|
||||
@@ -1255,6 +1296,7 @@ def register_handlers(dp: Dispatcher):
|
||||
& ~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_"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -28,33 +29,63 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
|
||||
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=("✅ Пункт 'Техподдержка' в меню" if menu_enabled else "🚫 Пункт 'Техподдержка' в меню"),
|
||||
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=("🔘 Тикеты" if mode == "tickets" else "⚪ Тикеты"), callback_data="admin_support_mode_tickets"),
|
||||
types.InlineKeyboardButton(text=("🔘 Контакт" if mode == "contact" else "⚪ Контакт"), callback_data="admin_support_mode_contact"),
|
||||
types.InlineKeyboardButton(text=("🔘 Оба" if mode == "both" else "⚪ Оба"), callback_data="admin_support_mode_both"),
|
||||
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="📝 Изменить описание", callback_data="admin_support_edit_desc")
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"),
|
||||
callback_data="admin_support_edit_desc"
|
||||
)
|
||||
])
|
||||
|
||||
# Notifications block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"),
|
||||
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=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"),
|
||||
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"
|
||||
)
|
||||
])
|
||||
@@ -62,13 +93,17 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
# SLA block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"),
|
||||
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=f"⏳ Время SLA: {sla_minutes} мин",
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes),
|
||||
callback_data="admin_support_set_sla_minutes"
|
||||
)
|
||||
])
|
||||
@@ -78,15 +113,18 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
mod_count = len(moderators)
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"🧑⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators"
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑⚖️ Модераторы: {count}").format(count=mod_count),
|
||||
callback_data="admin_support_list_moderators"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="➕ Назначить модератора", callback_data="admin_support_add_moderator"
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"),
|
||||
callback_data="admin_support_add_moderator"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="➖ Удалить модератора", callback_data="admin_support_remove_moderator"
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"),
|
||||
callback_data="admin_support_remove_moderator"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -107,8 +145,8 @@ async def show_support_settings(
|
||||
texts = get_texts(db_user.language)
|
||||
desc = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
"🛟 <b>Настройки поддержки</b>\n\n" +
|
||||
"Режим работы и видимость в меню. Ниже текущее описание меню поддержки:\n\n" +
|
||||
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"
|
||||
@@ -152,24 +190,6 @@ async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSess
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
from app.states import SupportSettingsStates
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below
|
||||
# we'll manage separate state below
|
||||
|
||||
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
class SupportAdvancedStates(StatesGroup):
|
||||
waiting_for_sla_minutes = State()
|
||||
waiting_for_moderator_id = State()
|
||||
@@ -178,11 +198,15 @@ class SupportAdvancedStates(StatesGroup):
|
||||
@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(
|
||||
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
|
||||
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="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
|
||||
@@ -192,63 +216,53 @@ async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db
|
||||
@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("❌ Введите корректное число минут (1-1440)")
|
||||
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="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer("✅ Значение SLA сохранено", reply_markup=markup)
|
||||
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(
|
||||
"🧑⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
|
||||
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="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
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 handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
tid = int(text)
|
||||
except Exception:
|
||||
await message.answer("❌ Введите корректный Telegram ID (число)")
|
||||
return
|
||||
if SupportSettingsService.add_moderator(tid):
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup)
|
||||
else:
|
||||
await message.answer("❌ Не удалось сохранить")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@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(
|
||||
"🧑⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
|
||||
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="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
@@ -260,24 +274,32 @@ async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, d
|
||||
@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("❌ Введите корректный Telegram ID (число)")
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)"))
|
||||
return
|
||||
ok = False
|
||||
if action == "remove_moderator":
|
||||
ok = SupportSettingsService.remove_moderator(tid)
|
||||
msg = "✅ Модератор удалён" if ok else "❌ Не удалось удалить"
|
||||
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 = "✅ Пользователь назначен модератором" if ok else "❌ Не удалось назначить"
|
||||
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="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(msg, reply_markup=markup)
|
||||
|
||||
@@ -285,13 +307,17 @@ async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSe
|
||||
@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("Список пуст", show_alert=True)
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True)
|
||||
return
|
||||
text = "🧑⚖️ <b>Модераторы</b>\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
|
||||
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="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
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()
|
||||
@@ -328,7 +354,10 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
|
||||
|
||||
kb_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(text="📨 Прислать текст", callback_data="admin_support_send_desc")
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"),
|
||||
callback_data="admin_support_send_desc"
|
||||
)
|
||||
])
|
||||
# Подготовим блок контакта (отдельным инлайном)
|
||||
from app.config import settings
|
||||
@@ -338,19 +367,19 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
|
||||
])
|
||||
|
||||
text_parts = [
|
||||
"📝 <b>Редактирование описания поддержки</b>",
|
||||
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 += [
|
||||
"",
|
||||
"<b>Контакт для режима \u00abКонтакт\u00bb</b>",
|
||||
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),
|
||||
@@ -364,24 +393,26 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
|
||||
@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="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer("✅ Описание обновлено.", reply_markup=markup)
|
||||
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="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
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)
|
||||
@@ -393,7 +424,7 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
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("Текст отправлен ниже")
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже"))
|
||||
|
||||
|
||||
@error_handler
|
||||
@@ -403,15 +434,15 @@ async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db:
|
||||
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:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
try:
|
||||
await callback.message.delete()
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await callback.answer("Сообщение удалено")
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено"))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
+727
-64
@@ -80,7 +80,7 @@ async def show_users_filters(
|
||||
state: FSMContext
|
||||
):
|
||||
|
||||
text = "⚙️ <b>Фильтры пользователей</b>\n\nВыберите фильтр для отображения пользователей:"
|
||||
text = ("⚙️ <b>Фильтры пользователей</b>\n\nВыберите фильтр для отображения пользователей:\n")
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
@@ -142,7 +142,7 @@ async def show_users_list(
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
|
||||
|
||||
button_text += f" | 📅 {format_time_ago(user.created_at)}"
|
||||
button_text += f" | 📅 {format_time_ago(user.created_at, db_user.language)}"
|
||||
|
||||
if len(button_text) > 60:
|
||||
short_name = user.full_name
|
||||
@@ -288,6 +288,464 @@ async def show_users_list_by_balance(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
await state.set_state(AdminStates.viewing_user_from_traffic_list)
|
||||
|
||||
user_service = UserService()
|
||||
users_data = await user_service.get_users_page(
|
||||
db, page=page, limit=10, order_by_traffic=True
|
||||
)
|
||||
|
||||
if not users_data["users"]:
|
||||
await callback.message.edit_text(
|
||||
"📶 Пользователи с трафиком не найдены",
|
||||
reply_markup=get_admin_users_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"👥 <b>Список пользователей по использованному трафику</b> (стр. {page}/{users_data['total_pages']})\n\n"
|
||||
text += "Нажмите на пользователя для управления:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in users_data["users"]:
|
||||
if user.status == UserStatus.ACTIVE.value:
|
||||
status_emoji = "✅"
|
||||
elif user.status == UserStatus.BLOCKED.value:
|
||||
status_emoji = "🚫"
|
||||
else:
|
||||
status_emoji = "🗑️"
|
||||
|
||||
if user.subscription:
|
||||
sub = user.subscription
|
||||
if sub.is_trial:
|
||||
subscription_emoji = "🎁"
|
||||
elif sub.is_active:
|
||||
subscription_emoji = "💎"
|
||||
else:
|
||||
subscription_emoji = "⏰"
|
||||
used = sub.traffic_used_gb or 0.0
|
||||
if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
|
||||
limit_display = f"{sub.traffic_limit_gb}"
|
||||
else:
|
||||
limit_display = "♾️"
|
||||
traffic_display = f"{used:.1f}/{limit_display} ГБ"
|
||||
else:
|
||||
subscription_emoji = "❌"
|
||||
traffic_display = "нет подписки"
|
||||
|
||||
button_text = f"{status_emoji} {subscription_emoji} {user.full_name}"
|
||||
button_text += f" | 📶 {traffic_display}"
|
||||
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
|
||||
|
||||
if len(button_text) > 60:
|
||||
short_name = user.full_name
|
||||
if len(short_name) > 20:
|
||||
short_name = short_name[:17] + "..."
|
||||
button_text = f"{status_emoji} {subscription_emoji} {short_name}"
|
||||
button_text += f" | 📶 {traffic_display}"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if users_data["total_pages"] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data["current_page"],
|
||||
users_data["total_pages"],
|
||||
"admin_users_traffic_list",
|
||||
"admin_users",
|
||||
db_user.language
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_last_activity(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
await state.set_state(AdminStates.viewing_user_from_last_activity_list)
|
||||
|
||||
user_service = UserService()
|
||||
users_data = await user_service.get_users_page(
|
||||
db,
|
||||
page=page,
|
||||
limit=10,
|
||||
order_by_last_activity=True,
|
||||
)
|
||||
|
||||
if not users_data["users"]:
|
||||
await callback.message.edit_text(
|
||||
"🕒 Пользователи с активностью не найдены",
|
||||
reply_markup=get_admin_users_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"👥 <b>Пользователи по активности</b> (стр. {page}/{users_data['total_pages']})\n\n"
|
||||
text += "Нажмите на пользователя для управления:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in users_data["users"]:
|
||||
if user.status == UserStatus.ACTIVE.value:
|
||||
status_emoji = "✅"
|
||||
elif user.status == UserStatus.BLOCKED.value:
|
||||
status_emoji = "🚫"
|
||||
else:
|
||||
status_emoji = "🗑️"
|
||||
|
||||
activity_display = (
|
||||
format_time_ago(user.last_activity, db_user.language)
|
||||
if user.last_activity
|
||||
else "неизвестно"
|
||||
)
|
||||
|
||||
subscription_emoji = "❌"
|
||||
if user.subscription:
|
||||
if user.subscription.is_trial:
|
||||
subscription_emoji = "🎁"
|
||||
elif user.subscription.is_active:
|
||||
subscription_emoji = "💎"
|
||||
else:
|
||||
subscription_emoji = "⏰"
|
||||
|
||||
button_text = f"{status_emoji} {subscription_emoji} {user.full_name}"
|
||||
button_text += f" | 🕒 {activity_display}"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if users_data["total_pages"] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data["current_page"],
|
||||
users_data["total_pages"],
|
||||
"admin_users_activity_list",
|
||||
"admin_users",
|
||||
db_user.language
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_spending(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
await state.set_state(AdminStates.viewing_user_from_spending_list)
|
||||
|
||||
user_service = UserService()
|
||||
users_data = await user_service.get_users_page(
|
||||
db,
|
||||
page=page,
|
||||
limit=10,
|
||||
order_by_total_spent=True,
|
||||
)
|
||||
|
||||
users = users_data["users"]
|
||||
if not users:
|
||||
await callback.message.edit_text(
|
||||
"💳 Пользователи с тратами не найдены",
|
||||
reply_markup=get_admin_users_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
spending_map = await user_service.get_user_spending_stats_map(
|
||||
db,
|
||||
[user.id for user in users],
|
||||
)
|
||||
|
||||
text = f"👥 <b>Пользователи по сумме трат</b> (стр. {page}/{users_data['total_pages']})\n\n"
|
||||
text += "Нажмите на пользователя для управления:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in users:
|
||||
stats = spending_map.get(
|
||||
user.id,
|
||||
{"total_spent": 0, "purchase_count": 0},
|
||||
)
|
||||
total_spent = stats.get("total_spent", 0)
|
||||
purchases = stats.get("purchase_count", 0)
|
||||
|
||||
status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" if user.status == UserStatus.BLOCKED.value else "🗑️"
|
||||
|
||||
button_text = (
|
||||
f"{status_emoji} {user.full_name}"
|
||||
f" | 💳 {settings.format_price(total_spent)}"
|
||||
f" | 🛒 {purchases}"
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if users_data["total_pages"] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data["current_page"],
|
||||
users_data["total_pages"],
|
||||
"admin_users_spending_list",
|
||||
"admin_users",
|
||||
db_user.language
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_purchases(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
await state.set_state(AdminStates.viewing_user_from_purchases_list)
|
||||
|
||||
user_service = UserService()
|
||||
users_data = await user_service.get_users_page(
|
||||
db,
|
||||
page=page,
|
||||
limit=10,
|
||||
order_by_purchase_count=True,
|
||||
)
|
||||
|
||||
users = users_data["users"]
|
||||
if not users:
|
||||
await callback.message.edit_text(
|
||||
"🛒 Пользователи с покупками не найдены",
|
||||
reply_markup=get_admin_users_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
spending_map = await user_service.get_user_spending_stats_map(
|
||||
db,
|
||||
[user.id for user in users],
|
||||
)
|
||||
|
||||
text = f"👥 <b>Пользователи по количеству покупок</b> (стр. {page}/{users_data['total_pages']})\n\n"
|
||||
text += "Нажмите на пользователя для управления:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in users:
|
||||
stats = spending_map.get(
|
||||
user.id,
|
||||
{"total_spent": 0, "purchase_count": 0},
|
||||
)
|
||||
total_spent = stats.get("total_spent", 0)
|
||||
purchases = stats.get("purchase_count", 0)
|
||||
|
||||
status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" if user.status == UserStatus.BLOCKED.value else "🗑️"
|
||||
|
||||
button_text = (
|
||||
f"{status_emoji} {user.full_name}"
|
||||
f" | 🛒 {purchases}"
|
||||
f" | 💳 {settings.format_price(total_spent)}"
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if users_data["total_pages"] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data["current_page"],
|
||||
users_data["total_pages"],
|
||||
"admin_users_purchases_list",
|
||||
"admin_users",
|
||||
db_user.language
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_campaign(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
await state.set_state(AdminStates.viewing_user_from_campaign_list)
|
||||
|
||||
user_service = UserService()
|
||||
users_data = await user_service.get_users_by_campaign_page(
|
||||
db,
|
||||
page=page,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
users = users_data.get("users", [])
|
||||
campaign_map = users_data.get("campaigns", {})
|
||||
|
||||
if not users:
|
||||
await callback.message.edit_text(
|
||||
"📢 Пользователи с кампанией не найдены",
|
||||
reply_markup=get_admin_users_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"👥 <b>Пользователи по кампании регистрации</b> (стр. {page}/{users_data['total_pages']})\n\n"
|
||||
text += "Нажмите на пользователя для управления:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in users:
|
||||
info = campaign_map.get(user.id, {})
|
||||
campaign_name = info.get("campaign_name") or "Без кампании"
|
||||
registered_at = info.get("registered_at")
|
||||
registered_display = format_datetime(registered_at) if registered_at else "неизвестно"
|
||||
|
||||
status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" if user.status == UserStatus.BLOCKED.value else "🗑️"
|
||||
|
||||
button_text = (
|
||||
f"{status_emoji} {user.full_name}"
|
||||
f" | 📢 {campaign_name}"
|
||||
f" | 📅 {registered_display}"
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if users_data["total_pages"] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data["current_page"],
|
||||
users_data["total_pages"],
|
||||
"admin_users_campaign_list",
|
||||
"admin_users",
|
||||
db_user.language
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_list_pagination_fixed(
|
||||
@@ -322,6 +780,91 @@ async def handle_users_balance_list_pagination(
|
||||
await show_users_list_by_balance(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_traffic_list_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_traffic(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка парсинга номера страницы: {e}")
|
||||
await show_users_list_by_traffic(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_activity_list_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_last_activity(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка парсинга номера страницы: {e}")
|
||||
await show_users_list_by_last_activity(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_spending_list_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_spending(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка парсинга номера страницы: {e}")
|
||||
await show_users_list_by_spending(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_purchases_list_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_purchases(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка парсинга номера страницы: {e}")
|
||||
await show_users_list_by_purchases(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_campaign_list_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_campaign(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка парсинга номера страницы: {e}")
|
||||
await show_users_list_by_campaign(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_user_search(
|
||||
@@ -830,66 +1373,97 @@ async def show_user_management(
|
||||
|
||||
user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
if user.status == UserStatus.ACTIVE.value:
|
||||
status_text = "✅ Активен"
|
||||
elif user.status == UserStatus.BLOCKED.value:
|
||||
status_text = "🚫 Заблокирован"
|
||||
elif user.status == UserStatus.DELETED.value:
|
||||
status_text = "🗑️ Удален"
|
||||
else:
|
||||
status_text = "❓ Неизвестно"
|
||||
|
||||
text = f"""
|
||||
👤 <b>Управление пользователем</b>
|
||||
|
||||
<b>Основная информация:</b>
|
||||
• Имя: {user.full_name}
|
||||
• ID: <code>{user.telegram_id}</code>
|
||||
• Username: @{user.username or 'не указан'}
|
||||
• Статус: {status_text}
|
||||
• Язык: {user.language}
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
<b>Финансы:</b>
|
||||
• Баланс: {settings.format_price(user.balance_kopeks)}
|
||||
• Транзакций: {profile['transactions_count']}
|
||||
status_map = {
|
||||
UserStatus.ACTIVE.value: texts.ADMIN_USER_STATUS_ACTIVE,
|
||||
UserStatus.BLOCKED.value: texts.ADMIN_USER_STATUS_BLOCKED,
|
||||
UserStatus.DELETED.value: texts.ADMIN_USER_STATUS_DELETED,
|
||||
}
|
||||
status_text = status_map.get(user.status, texts.ADMIN_USER_STATUS_UNKNOWN)
|
||||
|
||||
username_display = (
|
||||
f"@{user.username}" if user.username else texts.ADMIN_USER_USERNAME_NOT_SET
|
||||
)
|
||||
last_activity = (
|
||||
format_time_ago(user.last_activity, db_user.language)
|
||||
if user.last_activity
|
||||
else texts.ADMIN_USER_LAST_ACTIVITY_UNKNOWN
|
||||
)
|
||||
|
||||
sections = [
|
||||
texts.ADMIN_USER_MANAGEMENT_PROFILE.format(
|
||||
name=user.full_name,
|
||||
telegram_id=user.telegram_id,
|
||||
username=username_display,
|
||||
status=status_text,
|
||||
language=user.language,
|
||||
balance=settings.format_price(user.balance_kopeks),
|
||||
transactions=profile["transactions_count"],
|
||||
registration=format_datetime(user.created_at),
|
||||
last_activity=last_activity,
|
||||
registration_days=profile["registration_days"],
|
||||
)
|
||||
]
|
||||
|
||||
<b>Активность:</b>
|
||||
• Регистрация: {format_datetime(user.created_at)}
|
||||
• Последняя активность: {format_time_ago(user.last_activity) if user.last_activity else 'Неизвестно'}
|
||||
• Дней с регистрации: {profile['registration_days']}
|
||||
"""
|
||||
|
||||
if subscription:
|
||||
text += f"""
|
||||
<b>Подписка:</b>
|
||||
• Тип: {'🎁 Триал' if subscription.is_trial else '💎 Платная'}
|
||||
• Статус: {'✅ Активна' if subscription.is_active else '❌ Неактивна'}
|
||||
• До: {format_datetime(subscription.end_date)}
|
||||
• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ
|
||||
• Устройства: {subscription.device_limit}
|
||||
• Стран: {len(subscription.connected_squads)}
|
||||
"""
|
||||
subscription_type = (
|
||||
texts.ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL
|
||||
if subscription.is_trial
|
||||
else texts.ADMIN_USER_SUBSCRIPTION_TYPE_PAID
|
||||
)
|
||||
subscription_status = (
|
||||
texts.ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE
|
||||
if subscription.is_active
|
||||
else texts.ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE
|
||||
)
|
||||
traffic_usage = texts.ADMIN_USER_TRAFFIC_USAGE.format(
|
||||
used=f"{subscription.traffic_used_gb:.1f}",
|
||||
limit=subscription.traffic_limit_gb,
|
||||
)
|
||||
sections.append(
|
||||
texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION.format(
|
||||
type=subscription_type,
|
||||
status=subscription_status,
|
||||
end_date=format_datetime(subscription.end_date),
|
||||
traffic=traffic_usage,
|
||||
devices=subscription.device_limit,
|
||||
countries=len(subscription.connected_squads),
|
||||
)
|
||||
)
|
||||
else:
|
||||
text += "\n<b>Подписка:</b> Отсутствует"
|
||||
sections.append(texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE)
|
||||
|
||||
if user.promo_group:
|
||||
promo_group = user.promo_group
|
||||
text += f"""
|
||||
|
||||
<b>Промогруппа:</b>
|
||||
• Название: {promo_group.name}
|
||||
• Скидка на сервера: {promo_group.server_discount_percent}%
|
||||
• Скидка на трафик: {promo_group.traffic_discount_percent}%
|
||||
• Скидка на устройства: {promo_group.device_discount_percent}%
|
||||
"""
|
||||
sections.append(
|
||||
texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format(
|
||||
name=promo_group.name,
|
||||
server_discount=promo_group.server_discount_percent,
|
||||
traffic_discount=promo_group.traffic_discount_percent,
|
||||
device_discount=promo_group.device_discount_percent,
|
||||
)
|
||||
)
|
||||
else:
|
||||
text += "\n<b>Промогруппа:</b> Не назначена"
|
||||
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
|
||||
|
||||
text = "\n\n".join(sections)
|
||||
|
||||
# Проверяем состояние, чтобы определить, откуда пришел пользователь
|
||||
current_state = await state.get_state()
|
||||
if current_state == AdminStates.viewing_user_from_balance_list:
|
||||
back_callback = "admin_users_balance_filter"
|
||||
elif current_state == AdminStates.viewing_user_from_traffic_list:
|
||||
back_callback = "admin_users_traffic_filter"
|
||||
elif current_state == AdminStates.viewing_user_from_last_activity_list:
|
||||
back_callback = "admin_users_activity_filter"
|
||||
elif current_state == AdminStates.viewing_user_from_spending_list:
|
||||
back_callback = "admin_users_spending_filter"
|
||||
elif current_state == AdminStates.viewing_user_from_purchases_list:
|
||||
back_callback = "admin_users_purchases_filter"
|
||||
elif current_state == AdminStates.viewing_user_from_campaign_list:
|
||||
back_callback = "admin_users_campaign_filter"
|
||||
|
||||
# Базовая клавиатура профиля
|
||||
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
|
||||
@@ -1210,7 +1784,12 @@ async def show_inactive_users(
|
||||
for user in inactive_users[:10]:
|
||||
text += f"👤 {user.full_name}\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
text += f"📅 {format_time_ago(user.last_activity) if user.last_activity else 'Никогда'}\n\n"
|
||||
last_activity_display = (
|
||||
format_time_ago(user.last_activity, db_user.language)
|
||||
if user.last_activity
|
||||
else "Никогда"
|
||||
)
|
||||
text += f"📅 {last_activity_display}\n\n"
|
||||
|
||||
if len(inactive_users) > 10:
|
||||
text += f"... и еще {len(inactive_users) - 10} пользователей"
|
||||
@@ -1501,11 +2080,16 @@ async def extend_user_subscription(
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⏰ <b>Продление подписки</b>\n\n"
|
||||
"Введите количество дней для продления:\n"
|
||||
"• Например: 30, 7, 90\n"
|
||||
"• Максимум: 365 дней\n\n"
|
||||
"Введите количество дней для изменения:\n"
|
||||
"• Положительные значения продлят подписку\n"
|
||||
"• Отрицательные сократят срок подписки\n"
|
||||
"• Диапазон: от -365 до 365 дней (0 недопустимо)\n\n"
|
||||
"Или нажмите /cancel для отмены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="-7 дней", callback_data=f"admin_sub_extend_days_{user_id}_-7"),
|
||||
types.InlineKeyboardButton(text="-30 дней", callback_data=f"admin_sub_extend_days_{user_id}_-30")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="7 дней", callback_data=f"admin_sub_extend_days_{user_id}_7"),
|
||||
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_extend_days_{user_id}_30")
|
||||
@@ -1535,11 +2119,19 @@ async def process_subscription_extension_days(
|
||||
user_id = int(parts[-2])
|
||||
days = int(parts[-1])
|
||||
|
||||
if days == 0 or days < -365 or days > 365:
|
||||
await callback.answer("❌ Количество дней должно быть от -365 до 365, исключая 0", show_alert=True)
|
||||
return
|
||||
|
||||
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
|
||||
|
||||
|
||||
if success:
|
||||
if days > 0:
|
||||
action_text = f"продлена на {days} дней"
|
||||
else:
|
||||
action_text = f"уменьшена на {abs(days)} дней"
|
||||
await callback.message.edit_text(
|
||||
f"✅ Подписка пользователя продлена на {days} дней",
|
||||
f"✅ Подписка пользователя {action_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
@@ -1574,15 +2166,19 @@ async def process_subscription_extension_text(
|
||||
try:
|
||||
days = int(message.text.strip())
|
||||
|
||||
if days <= 0 or days > 365:
|
||||
await message.answer("❌ Количество дней должно быть от 1 до 365")
|
||||
if days == 0 or days < -365 or days > 365:
|
||||
await message.answer("❌ Количество дней должно быть от -365 до 365, исключая 0")
|
||||
return
|
||||
|
||||
|
||||
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
|
||||
|
||||
|
||||
if success:
|
||||
if days > 0:
|
||||
action_text = f"продлена на {days} дней"
|
||||
else:
|
||||
action_text = f"уменьшена на {abs(days)} дней"
|
||||
await message.answer(
|
||||
f"✅ Подписка пользователя продлена на {days} дней",
|
||||
f"✅ Подписка пользователя {action_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
@@ -2536,9 +3132,12 @@ async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
|
||||
if days > 0:
|
||||
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
|
||||
else:
|
||||
logger.info(f"Админ {admin_id} сократил подписку пользователя {user_id} на {abs(days)} дней")
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка продления подписки: {e}")
|
||||
return False
|
||||
@@ -2661,13 +3260,34 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
logger.error(f"У пользователя {user_id} уже есть подписка")
|
||||
return False
|
||||
|
||||
trial_squads: list[str] = []
|
||||
|
||||
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:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось подобрать сквад при выдаче подписки админом %s: %s",
|
||||
admin_id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.DEFAULT_DEVICE_LIMIT,
|
||||
connected_squads=[settings.TRIAL_SQUAD_UUID] if settings.TRIAL_SQUAD_UUID else []
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
@@ -3340,6 +3960,31 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("admin_users_balance_list_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_traffic_list_pagination,
|
||||
F.data.startswith("admin_users_traffic_list_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_activity_list_pagination,
|
||||
F.data.startswith("admin_users_activity_list_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_spending_list_pagination,
|
||||
F.data.startswith("admin_users_spending_list_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_purchases_list_pagination,
|
||||
F.data.startswith("admin_users_purchases_list_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_campaign_list_pagination,
|
||||
F.data.startswith("admin_users_campaign_list_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_user_search,
|
||||
F.data == "admin_users_search"
|
||||
@@ -3545,9 +4190,27 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_list_by_balance,
|
||||
F.data.startswith("admin_users_balance_list_page_")
|
||||
show_users_list_by_traffic,
|
||||
F.data == "admin_users_traffic_filter"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_list_by_last_activity,
|
||||
F.data == "admin_users_activity_filter"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_list_by_spending,
|
||||
F.data == "admin_users_spending_filter"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_list_by_purchases,
|
||||
F.data == "admin_users_purchases_filter"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_list_by_campaign,
|
||||
F.data == "admin_users_campaign_filter"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
+154
-38
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -1002,9 +1003,7 @@ async def process_pal24_payment_amount(
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not (
|
||||
payment_result.get("link_url") or payment_result.get("link_page_url")
|
||||
):
|
||||
if not payment_result:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
@@ -1014,49 +1013,146 @@ async def process_pal24_payment_amount(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = (
|
||||
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")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAL24_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
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")],
|
||||
]
|
||||
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"
|
||||
"🏦 <b>Оплата через PayPalych</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID счета: {bill_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\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(),
|
||||
)
|
||||
|
||||
@@ -1231,28 +1327,48 @@ async def check_pal24_payment_status(
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
payment_link = payment.link_page_url or payment.link_url
|
||||
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 (СБП):\n\n",
|
||||
f"🆔 ID счета: {payment.bill_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",
|
||||
"🏦 Статус платежа 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("\n✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
message_lines.append("")
|
||||
message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"NEW", "PROCESS"}:
|
||||
message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
|
||||
if payment_link:
|
||||
message_lines.append(f"\n🔗 Ссылка на оплату: {payment_link}")
|
||||
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"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
|
||||
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer("".join(message_lines), show_alert=True)
|
||||
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}")
|
||||
|
||||
+773
-19
@@ -1,4 +1,7 @@
|
||||
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
|
||||
@@ -7,10 +10,18 @@ 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, get_language_selection_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,
|
||||
@@ -18,10 +29,117 @@ from app.services.subscription_checkout_service import (
|
||||
)
|
||||
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,
|
||||
@@ -45,19 +163,32 @@ 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)
|
||||
|
||||
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_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(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",
|
||||
)
|
||||
@@ -65,17 +196,6 @@ async def show_main_menu(
|
||||
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} отмечен как имевший платную подписку")
|
||||
|
||||
|
||||
async def show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -98,6 +218,558 @@ 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,
|
||||
@@ -203,19 +875,32 @@ 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)
|
||||
|
||||
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_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(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",
|
||||
)
|
||||
@@ -302,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:
|
||||
@@ -328,6 +1042,46 @@ def register_handlers(dp: Dispatcher):
|
||||
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"
|
||||
|
||||
+142
-16
@@ -30,8 +30,15 @@ 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__)
|
||||
@@ -322,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"
|
||||
)
|
||||
@@ -351,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
|
||||
@@ -728,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"
|
||||
)
|
||||
@@ -893,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"
|
||||
)
|
||||
@@ -952,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"
|
||||
)
|
||||
@@ -1117,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"
|
||||
)
|
||||
@@ -1244,6 +1326,35 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
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:
|
||||
@@ -1357,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:
|
||||
|
||||
+1954
-1240
File diff suppressed because it is too large
Load Diff
@@ -412,7 +412,6 @@ async def show_my_tickets(
|
||||
# Добавим кнопку перехода к закрытым
|
||||
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")])
|
||||
# Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"),
|
||||
@@ -456,7 +455,6 @@ async def show_my_tickets_closed(
|
||||
data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets]
|
||||
kb = get_my_tickets_keyboard(data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_closed_page_")
|
||||
kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")])
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"),
|
||||
|
||||
+1030
-317
File diff suppressed because it is too large
Load Diff
+111
-36
@@ -121,6 +121,7 @@ def get_main_menu_keyboard(
|
||||
show_resume_checkout: bool = False,
|
||||
*,
|
||||
is_moderator: bool = False,
|
||||
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -230,6 +231,11 @@ def get_main_menu_keyboard(
|
||||
)
|
||||
])
|
||||
|
||||
if custom_buttons:
|
||||
for button in custom_buttons:
|
||||
if isinstance(button, InlineKeyboardButton):
|
||||
keyboard.append([button])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
|
||||
@@ -237,41 +243,23 @@ def get_main_menu_keyboard(
|
||||
]
|
||||
])
|
||||
|
||||
# Server status button
|
||||
server_status_mode = settings.get_server_status_mode()
|
||||
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
|
||||
|
||||
if server_status_mode == "external_link":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=server_status_text, url=status_url)
|
||||
])
|
||||
elif server_status_mode == "external_link_miniapp":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=server_status_text,
|
||||
web_app=types.WebAppInfo(url=status_url),
|
||||
)
|
||||
])
|
||||
elif server_status_mode == "xray":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=server_status_text, callback_data="menu_server_status")
|
||||
])
|
||||
|
||||
# Support button is configurable (runtime via service)
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
support_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
support_row = []
|
||||
if support_enabled:
|
||||
support_row.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"))
|
||||
support_row.append(InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules"))
|
||||
keyboard.append(support_row)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_INFO", "ℹ️ Инфо"),
|
||||
callback_data="menu_info",
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard.append([
|
||||
@@ -298,6 +286,82 @@ def get_main_menu_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_info_menu_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
show_privacy_policy: bool = False,
|
||||
show_public_offer: bool = False,
|
||||
show_faq: bool = False,
|
||||
show_promo_groups: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
buttons: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
if show_faq:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_FAQ", "❓ FAQ"),
|
||||
callback_data="menu_faq",
|
||||
)
|
||||
])
|
||||
|
||||
if show_promo_groups:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_PROMO_GROUPS_INFO", "🎯 Промогруппы"),
|
||||
callback_data="menu_info_promo_groups",
|
||||
)
|
||||
])
|
||||
|
||||
if show_privacy_policy:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_PRIVACY_POLICY", "🛡️ Политика конф."),
|
||||
callback_data="menu_privacy_policy",
|
||||
)
|
||||
])
|
||||
|
||||
if show_public_offer:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_PUBLIC_OFFER", "📄 Оферта"),
|
||||
callback_data="menu_public_offer",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
|
||||
])
|
||||
|
||||
server_status_mode = settings.get_server_status_mode()
|
||||
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
|
||||
|
||||
if server_status_mode == "external_link":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
buttons.append([InlineKeyboardButton(text=server_status_text, url=status_url)])
|
||||
elif server_status_mode == "external_link_miniapp":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=server_status_text,
|
||||
web_app=types.WebAppInfo(url=status_url),
|
||||
)
|
||||
])
|
||||
elif server_status_mode == "xray":
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=server_status_text,
|
||||
callback_data="menu_server_status",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]:
|
||||
if not settings.is_happ_download_button_enabled():
|
||||
return None
|
||||
@@ -1646,14 +1710,25 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
for days in available_periods:
|
||||
if days in prices:
|
||||
period_display = format_period_description(days, language)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display} - {texts.format_price(prices[days])}",
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
if days not in prices:
|
||||
continue
|
||||
|
||||
price_info = prices[days]
|
||||
|
||||
if isinstance(price_info, dict):
|
||||
final_price = price_info.get("final")
|
||||
if final_price is None:
|
||||
final_price = price_info.get("original", 0)
|
||||
else:
|
||||
final_price = price_info
|
||||
|
||||
period_display = format_period_description(days, language)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display} - {texts.format_price(final_price)}",
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
{
|
||||
"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",
|
||||
@@ -53,8 +75,30 @@
|
||||
"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",
|
||||
@@ -97,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!",
|
||||
@@ -162,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.",
|
||||
@@ -214,17 +277,44 @@
|
||||
"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",
|
||||
@@ -298,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",
|
||||
@@ -426,6 +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": "📨 Рассылки",
|
||||
@@ -38,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": "Это базовая группа.",
|
||||
@@ -72,6 +102,12 @@
|
||||
"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}",
|
||||
@@ -93,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 ",
|
||||
@@ -103,6 +140,7 @@
|
||||
"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": "✅ Подтвердить",
|
||||
@@ -120,6 +158,7 @@
|
||||
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
|
||||
"CONTACT_SUPPORT": "💬 Написать в поддержку",
|
||||
"CONTINUE": "➡️ Продолжить",
|
||||
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram не позволяет боту запросить контакт из-за ваших настроек приватности.</b>\n\nРазрешите отправку контакта в настройках Telegram или поделитесь данными вручную в ответ на сообщение.",
|
||||
"CONTINUE_BUTTON": "✅ Продолжить",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
|
||||
"CREATE_INVITE": "📝 Создать приглашение",
|
||||
@@ -128,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",
|
||||
@@ -151,12 +203,57 @@
|
||||
"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": "🤝 Партнерка",
|
||||
@@ -234,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": "🧪 Тестовая подписка",
|
||||
@@ -297,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": "❌ Пользователь не найден",
|
||||
@@ -428,6 +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": "Нажмите на пакет, чтобы включить или выключить его отображение."
|
||||
}
|
||||
|
||||
@@ -701,7 +701,7 @@ 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:
|
||||
@@ -748,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>", ""]
|
||||
|
||||
@@ -971,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,
|
||||
|
||||
@@ -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
|
||||
@@ -15,9 +15,12 @@ from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.discount_offer import (
|
||||
deactivate_expired_offers,
|
||||
get_latest_claimed_offer_for_user,
|
||||
upsert_discount_offer,
|
||||
)
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.database.crud.notification import (
|
||||
clear_notification_by_type,
|
||||
notification_sent,
|
||||
record_notification,
|
||||
)
|
||||
@@ -33,12 +36,15 @@ from app.database.crud.user import (
|
||||
get_inactive_users,
|
||||
get_user_by_id,
|
||||
subtract_user_balance,
|
||||
cleanup_expired_promo_offer_discounts,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPIError,
|
||||
@@ -178,6 +184,17 @@ class MonitoringService:
|
||||
if expired_offers:
|
||||
logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений")
|
||||
|
||||
expired_active_discounts = await cleanup_expired_promo_offer_discounts(db)
|
||||
if expired_active_discounts:
|
||||
logger.info(
|
||||
"🧹 Сброшено %s активных скидок промо-предложений с истекшим сроком",
|
||||
expired_active_discounts,
|
||||
)
|
||||
|
||||
cleaned_test_access = await promo_offer_service.cleanup_expired_test_access(db)
|
||||
if cleaned_test_access:
|
||||
logger.info(f"🧹 Отозвано {cleaned_test_access} истекших тестовых доступов к сквадам")
|
||||
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
@@ -472,6 +489,10 @@ class MonitoringService:
|
||||
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
notifications_allowed = (
|
||||
NotificationSettingsService.are_notifications_globally_enabled()
|
||||
and NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
@@ -550,6 +571,22 @@ class MonitoringService:
|
||||
user.remnawave_uuid,
|
||||
api_error,
|
||||
)
|
||||
|
||||
if notifications_allowed:
|
||||
if not await notification_sent(
|
||||
db,
|
||||
user.id,
|
||||
subscription.id,
|
||||
"trial_channel_unsubscribed",
|
||||
):
|
||||
sent = await self._send_trial_channel_unsubscribed_notification(user)
|
||||
if sent:
|
||||
await record_notification(
|
||||
db,
|
||||
user.id,
|
||||
subscription.id,
|
||||
"trial_channel_unsubscribed",
|
||||
)
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
@@ -575,6 +612,12 @@ class MonitoringService:
|
||||
api_error,
|
||||
)
|
||||
|
||||
await clear_notification_by_type(
|
||||
db,
|
||||
subscription.id,
|
||||
"trial_channel_unsubscribed",
|
||||
)
|
||||
|
||||
if disabled_count or restored_count:
|
||||
await self._log_monitoring_event(
|
||||
db,
|
||||
@@ -649,15 +692,15 @@ class MonitoringService:
|
||||
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"):
|
||||
percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
notification_type="expired_discount_wave2",
|
||||
discount_percent=percent,
|
||||
bonus_amount_kopeks=bonus_amount,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=valid_hours,
|
||||
effect_type="percent_discount",
|
||||
)
|
||||
success = await self._send_expired_discount_notification(
|
||||
user,
|
||||
@@ -666,7 +709,6 @@ class MonitoringService:
|
||||
offer.expires_at,
|
||||
offer.id,
|
||||
"second",
|
||||
bonus_amount,
|
||||
)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, "expired_discount_wave2")
|
||||
@@ -679,15 +721,15 @@ class MonitoringService:
|
||||
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"):
|
||||
percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
notification_type="expired_discount_wave3",
|
||||
discount_percent=percent,
|
||||
bonus_amount_kopeks=bonus_amount,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=valid_hours,
|
||||
effect_type="percent_discount",
|
||||
)
|
||||
success = await self._send_expired_discount_notification(
|
||||
user,
|
||||
@@ -696,7 +738,6 @@ class MonitoringService:
|
||||
offer.expires_at,
|
||||
offer.id,
|
||||
"third",
|
||||
bonus_amount,
|
||||
trigger_days=trigger_days,
|
||||
)
|
||||
if success:
|
||||
@@ -750,6 +791,85 @@ class MonitoringService:
|
||||
|
||||
return subscriptions
|
||||
|
||||
@staticmethod
|
||||
def _get_user_promo_offer_discount_percent(user: Optional[User]) -> int:
|
||||
if not user:
|
||||
return 0
|
||||
|
||||
try:
|
||||
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||||
if expires_at and expires_at <= datetime.utcnow():
|
||||
return 0
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
@staticmethod
|
||||
async def _consume_user_promo_offer_discount(db: AsyncSession, user: User) -> None:
|
||||
percent = MonitoringService._get_user_promo_offer_discount_percent(user)
|
||||
if percent <= 0:
|
||||
return
|
||||
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
log_payload = {
|
||||
"offer_id": None,
|
||||
"percent": percent,
|
||||
"source": source,
|
||||
"effect_type": None,
|
||||
}
|
||||
|
||||
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 resolve latest claimed promo offer for user %s: %s",
|
||||
user.id,
|
||||
lookup_error,
|
||||
)
|
||||
offer = None
|
||||
|
||||
if offer:
|
||||
log_payload["offer_id"] = offer.id
|
||||
log_payload["effect_type"] = offer.effect_type
|
||||
if not log_payload["percent"] and offer.discount_percent:
|
||||
log_payload["percent"] = offer.discount_percent
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
offer_id=log_payload.get("offer_id"),
|
||||
action="consumed",
|
||||
source=log_payload.get("source"),
|
||||
percent=log_payload.get("percent"),
|
||||
effect_type=log_payload.get("effect_type"),
|
||||
details={"reason": "autopay_consumed"},
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer autopay 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 autopay log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
async def _process_autopayments(self, db: AsyncSession):
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
@@ -782,17 +902,26 @@ class MonitoringService:
|
||||
continue
|
||||
|
||||
renewal_cost = settings.PRICE_30_DAYS
|
||||
|
||||
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
|
||||
charge_amount = renewal_cost
|
||||
promo_discount_value = 0
|
||||
|
||||
if renewal_cost > 0 and promo_discount_percent > 0:
|
||||
charge_amount, promo_discount_value = apply_percentage_discount(
|
||||
renewal_cost,
|
||||
promo_discount_percent,
|
||||
)
|
||||
|
||||
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
|
||||
if autopay_key in self._notified_users:
|
||||
continue
|
||||
|
||||
if user.balance_kopeks >= renewal_cost:
|
||||
|
||||
if user.balance_kopeks >= charge_amount:
|
||||
success = await subtract_user_balance(
|
||||
db, user, renewal_cost,
|
||||
db, user, charge_amount,
|
||||
"Автопродление подписки"
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
await extend_subscription(db, subscription, 30)
|
||||
await self.subscription_service.update_remnawave_user(
|
||||
@@ -801,22 +930,30 @@ class MonitoringService:
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="автопродление подписки",
|
||||
)
|
||||
|
||||
|
||||
if promo_discount_value > 0:
|
||||
await self._consume_user_promo_offer_discount(db, user)
|
||||
|
||||
if self.bot:
|
||||
await self._send_autopay_success_notification(user, renewal_cost, 30)
|
||||
|
||||
await self._send_autopay_success_notification(user, charge_amount, 30)
|
||||
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно")
|
||||
logger.info(
|
||||
"💳 Автопродление подписки пользователя %s успешно (списано %s, скидка %s%%)",
|
||||
user.telegram_id,
|
||||
charge_amount,
|
||||
promo_discount_percent,
|
||||
)
|
||||
else:
|
||||
failed_count += 1
|
||||
if self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
|
||||
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
|
||||
else:
|
||||
failed_count += 1
|
||||
if self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
|
||||
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
|
||||
|
||||
if processed_count > 0 or failed_count > 0:
|
||||
@@ -1040,6 +1177,69 @@ class MonitoringService:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_trial_channel_unsubscribed_notification(self, user: User) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
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)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
buttons = []
|
||||
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)
|
||||
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, "уведомление об отписке от канала"):
|
||||
return True
|
||||
logger.error(
|
||||
"Ошибка Telegram API при отправке уведомления об отписке от канала пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления об отписке от канала пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
@@ -1096,7 +1296,6 @@ class MonitoringService:
|
||||
expires_at: datetime,
|
||||
offer_id: int,
|
||||
wave: str,
|
||||
bonus_amount: int,
|
||||
trigger_days: int = None,
|
||||
) -> bool:
|
||||
try:
|
||||
@@ -1107,8 +1306,8 @@ class MonitoringService:
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
|
||||
(
|
||||
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
|
||||
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
|
||||
"Предложение действует до {expires_at}."
|
||||
"Активируйте предложение, чтобы получить дополнительную скидку. "
|
||||
"Она суммируется с вашей промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -1116,14 +1315,13 @@ class MonitoringService:
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
|
||||
(
|
||||
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
|
||||
"Скидка действует до {expires_at}."
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
|
||||
"Она суммируется с промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
bonus=settings.format_price(bonus_amount),
|
||||
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
|
||||
trigger_days=trigger_days or "",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
@@ -21,6 +20,7 @@ class NotificationSettingsService:
|
||||
_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,
|
||||
@@ -139,6 +139,14 @@ class NotificationSettingsService:
|
||||
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:
|
||||
|
||||
@@ -872,9 +872,55 @@ class PaymentService:
|
||||
logger.error("Pal24 не вернул bill_id: %s", response)
|
||||
return None
|
||||
|
||||
link_url = response.get("link_url")
|
||||
link_page_url = response.get("link_page_url")
|
||||
primary_link = link_page_url or link_url
|
||||
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,
|
||||
@@ -887,11 +933,12 @@ class PaymentService:
|
||||
type_=response.get("type", "normal"),
|
||||
currency=response.get("currency", "RUB"),
|
||||
link_url=primary_link,
|
||||
link_page_url=link_page_url or link_url,
|
||||
link_page_url=secondary_link,
|
||||
ttl=ttl_seconds,
|
||||
metadata={
|
||||
"raw_response": response,
|
||||
"language": language,
|
||||
**({"links": metadata_links} if metadata_links else {}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -899,9 +946,12 @@ class PaymentService:
|
||||
"bill_id": bill_id,
|
||||
"order_id": order_id,
|
||||
"link_url": primary_link,
|
||||
"link_page_url": link_page_url or link_url,
|
||||
"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(
|
||||
|
||||
@@ -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,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()
|
||||
@@ -113,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)
|
||||
@@ -140,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,18 +1,25 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
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
|
||||
@@ -34,6 +41,16 @@ class RemnaWaveService:
|
||||
|
||||
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:
|
||||
@@ -72,33 +89,41 @@ class RemnaWaveService:
|
||||
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:
|
||||
@@ -600,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
|
||||
@@ -652,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
|
||||
@@ -707,7 +734,7 @@ 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,
|
||||
@@ -744,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:
|
||||
@@ -987,15 +1014,6 @@ class RemnaWaveService:
|
||||
logger.error(f"Error removing users from squad: {e}")
|
||||
return False
|
||||
|
||||
async def delete_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.get_api_client() 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.get_api_client() as api:
|
||||
@@ -1030,15 +1048,6 @@ 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.get_api_client() 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.get_api_client() as api:
|
||||
@@ -1134,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
|
||||
@@ -1168,22 +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.subscription_crypto_link = ""
|
||||
user.subscription.updated_at = datetime.utcnow()
|
||||
user.subscription.updated_at = self._now_in_panel_timezone()
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1352,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
|
||||
|
||||
@@ -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
|
||||
@@ -75,16 +76,80 @@ 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,
|
||||
@@ -106,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}")
|
||||
@@ -216,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,
|
||||
@@ -281,7 +346,7 @@ class SubscriptionService:
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
await api.disable_user(user_uuid)
|
||||
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
|
||||
return True
|
||||
@@ -301,7 +366,7 @@ 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
|
||||
@@ -319,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
|
||||
|
||||
@@ -338,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
|
||||
@@ -585,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:
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.database.universal_migration import ensure_default_web_api_token
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import Settings, settings
|
||||
from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices
|
||||
from app.database.crud.system_setting import (
|
||||
delete_system_setting,
|
||||
upsert_system_setting,
|
||||
@@ -56,95 +56,172 @@ class ChoiceOption:
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ReadOnlySettingError(RuntimeError):
|
||||
"""Исключение, выбрасываемое при попытке изменить настройку только для чтения."""
|
||||
|
||||
|
||||
class BotConfigurationService:
|
||||
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
|
||||
|
||||
READ_ONLY_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"}
|
||||
PLAIN_TEXT_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"}
|
||||
|
||||
CATEGORY_TITLES: Dict[str, str] = {
|
||||
"SUPPORT": "💬 Ссылка на поддержку",
|
||||
"LOCALIZATION": "🌍 Языковые настройки",
|
||||
"MAINTENANCE": "🛠️ Режим обслуживания",
|
||||
"CORE": "🤖 Основные настройки",
|
||||
"SUPPORT": "💬 Поддержка и тикеты",
|
||||
"LOCALIZATION": "🌍 Языки интерфейса",
|
||||
"CHANNEL": "📣 Обязательная подписка",
|
||||
"ADMIN_NOTIFICATIONS": "🔔 Уведомления админам",
|
||||
"ADMIN_REPORTS": "📊 Автоматические отчеты",
|
||||
"TRIAL": "🎁 Триальная подписка",
|
||||
"PAID_SUBSCRIPTION": "💰 Платные подписки",
|
||||
"PERIODS": "📅 Периоды подписки",
|
||||
"SUBSCRIPTION_PRICES": "💵 Цены за периоды",
|
||||
"TRAFFIC": "🚦 Настройки трафика",
|
||||
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
|
||||
"DISCOUNTS": "🎯 Промо и скидки",
|
||||
"PAYMENT": "⚙️ Общие настройки платежей",
|
||||
"PAYMENT": "💳 Общие платежные настройки",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "💎 CryptoBot",
|
||||
"YOOKASSA": "💸 YooKassa",
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 MulenPay",
|
||||
"PAL24": "🏦 Pal24/PayPalych",
|
||||
"REMNAWAVE": "🔗 RemnaWave API",
|
||||
"REFERRAL": "🤝 Реферальная система",
|
||||
"PAL24": "🏦 PAL24 / PayPalych",
|
||||
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
|
||||
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
|
||||
"PERIODS": "📆 Периоды подписок",
|
||||
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
|
||||
"TRAFFIC": "📊 Трафик",
|
||||
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
|
||||
"TRIAL": "🎁 Пробный период",
|
||||
"REFERRAL": "👥 Реферальная программа",
|
||||
"AUTOPAY": "🔄 Автопродление",
|
||||
"INTERFACE_BRANDING": "🖼️ Визуальные настройки",
|
||||
"INTERFACE_SUBSCRIPTION": "🔗 Скрыть ссылку подписки",
|
||||
"CONNECT_BUTTON": "🚀 Кнопка «Подключиться»",
|
||||
"HAPP": "🅷 Happ настройки",
|
||||
"SKIP": "⚡ Быстрый старт",
|
||||
"ADDITIONAL": "📱 Приложения и DeepLinks",
|
||||
"DATABASE": "🗄️ Режим БД",
|
||||
"POSTGRES": "🐘 PostgreSQL",
|
||||
"SQLITE": "💾 SQLite",
|
||||
"REDIS": "🧠 Redis",
|
||||
"MONITORING": "📈 Общий мониторинг",
|
||||
"NOTIFICATIONS": "🔔 Уведомления пользователям",
|
||||
"SERVER": "🖥️ Статус серверов",
|
||||
"BACKUP": "💾 Система бэкапов",
|
||||
"VERSION": "🔄 Обновления",
|
||||
"LOG": "📝 Логирование",
|
||||
"ADMIN_NOTIFICATIONS": "📣 Оповещения администраторам",
|
||||
"ADMIN_REPORTS": "🗂 Автоматические отчеты",
|
||||
"INTERFACE": "🎨 Интерфейс и брендинг",
|
||||
"INTERFACE_BRANDING": "🖼️ Брендинг",
|
||||
"INTERFACE_SUBSCRIPTION": "🔗 Ссылка на подписку",
|
||||
"CONNECT_BUTTON": "🚀 Кнопка подключения",
|
||||
"MINIAPP": "📱 Mini App",
|
||||
"HAPP": "🅷 Happ",
|
||||
"SKIP": "⚡ Быстрый старт",
|
||||
"ADDITIONAL": "📱 Дополнительные приложения",
|
||||
"DATABASE": "💾 База данных",
|
||||
"POSTGRES": "🐘 PostgreSQL",
|
||||
"SQLITE": "🧱 SQLite",
|
||||
"REDIS": "🧠 Redis",
|
||||
"REMNAWAVE": "🌐 RemnaWave API",
|
||||
"SERVER_STATUS": "📊 Статус серверов",
|
||||
"MONITORING": "📈 Мониторинг",
|
||||
"MAINTENANCE": "🔧 Обслуживание",
|
||||
"BACKUP": "💾 Резервные копии",
|
||||
"VERSION": "🔄 Проверка версий",
|
||||
"WEB_API": "⚡ Web API",
|
||||
"WEBHOOK": "🌐 Webhook",
|
||||
"WEB_API": "🌐 Web API",
|
||||
"DEBUG": "🔧 Режим разработки",
|
||||
"LOG": "📝 Логирование",
|
||||
"DEBUG": "🧪 Режим разработки",
|
||||
}
|
||||
|
||||
CATEGORY_DESCRIPTIONS: Dict[str, str] = {
|
||||
"CORE": "Базовые параметры работы бота и обязательные ссылки.",
|
||||
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
|
||||
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
|
||||
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"MULENPAY": "Платежи MulenPay и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
"TELEGRAM": "Telegram Stars и их стоимость.",
|
||||
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
|
||||
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
|
||||
"PERIODS": "Доступные периоды подписок и продлений.",
|
||||
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
|
||||
"TRAFFIC": "Лимиты трафика и стратегии сброса.",
|
||||
"TRAFFIC_PACKAGES": "Цены пакетов трафика и конфигурация предложений.",
|
||||
"TRIAL": "Длительность и ограничения пробного периода.",
|
||||
"REFERRAL": "Бонусы и пороги реферальной программы.",
|
||||
"AUTOPAY": "Настройки автопродления и минимальный баланс.",
|
||||
"NOTIFICATIONS": "Пользовательские уведомления и кэширование сообщений.",
|
||||
"ADMIN_NOTIFICATIONS": "Оповещения админам о событиях и тикетах.",
|
||||
"ADMIN_REPORTS": "Автоматические отчеты для команды.",
|
||||
"INTERFACE": "Глобальные параметры интерфейса и брендирования.",
|
||||
"INTERFACE_BRANDING": "Логотип и фирменный стиль.",
|
||||
"INTERFACE_SUBSCRIPTION": "Отображение ссылок и кнопок подписок.",
|
||||
"CONNECT_BUTTON": "Поведение кнопки «Подключиться» и miniapp.",
|
||||
"MINIAPP": "Mini App и кастомные ссылки.",
|
||||
"HAPP": "Интеграция Happ и связанные ссылки.",
|
||||
"SKIP": "Настройки быстрого старта и гайд по подключению.",
|
||||
"ADDITIONAL": "Конфигурация app-config.json, deep links и кеша.",
|
||||
"DATABASE": "Режим работы базы данных и пути до файлов.",
|
||||
"POSTGRES": "Параметры подключения к PostgreSQL.",
|
||||
"SQLITE": "Файл SQLite и резервные параметры.",
|
||||
"REDIS": "Подключение к Redis для кэша.",
|
||||
"REMNAWAVE": "Параметры авторизации и интеграция с RemnaWave API.",
|
||||
"SERVER_STATUS": "Отображение статуса серверов и external URL.",
|
||||
"MONITORING": "Интервалы мониторинга и хранение логов.",
|
||||
"MAINTENANCE": "Режим обслуживания, сообщения и интервалы.",
|
||||
"BACKUP": "Резервное копирование и расписание.",
|
||||
"VERSION": "Отслеживание обновлений репозитория.",
|
||||
"WEB_API": "Web API, токены и права доступа.",
|
||||
"WEBHOOK": "Пути и секреты вебхуков.",
|
||||
"LOG": "Уровни логирования и ротация.",
|
||||
"DEBUG": "Отладочные функции и безопасный режим.",
|
||||
}
|
||||
|
||||
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
|
||||
"DATABASE_URL": "DATABASE",
|
||||
"DATABASE_MODE": "DATABASE",
|
||||
"LOCALES_PATH": "DATABASE",
|
||||
"DEFAULT_DEVICE_LIMIT": "PAID_SUBSCRIPTION",
|
||||
"DEFAULT_TRAFFIC_LIMIT_GB": "PAID_SUBSCRIPTION",
|
||||
"MAX_DEVICES_LIMIT": "PAID_SUBSCRIPTION",
|
||||
"PRICE_PER_DEVICE": "PAID_SUBSCRIPTION",
|
||||
"LOCALES_PATH": "LOCALIZATION",
|
||||
"CHANNEL_SUB_ID": "CHANNEL",
|
||||
"CHANNEL_LINK": "CHANNEL",
|
||||
"CHANNEL_IS_REQUIRED_SUB": "CHANNEL",
|
||||
"DEFAULT_LANGUAGE": "LOCALIZATION",
|
||||
"AVAILABLE_LANGUAGES": "LOCALIZATION",
|
||||
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
|
||||
"DEFAULT_DEVICE_LIMIT": "SUBSCRIPTIONS_CORE",
|
||||
"DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE",
|
||||
"MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE",
|
||||
"PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
|
||||
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
|
||||
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
|
||||
"FIXED_TRAFFIC_LIMIT_GB": "TRAFFIC",
|
||||
"AVAILABLE_SUBSCRIPTION_PERIODS": "PERIODS",
|
||||
"AVAILABLE_RENEWAL_PERIODS": "PERIODS",
|
||||
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_14_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_30_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_60_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_TRAFFIC_5GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_10GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_25GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_50GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_100GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_250GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_500GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_1000GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_UNLIMITED": "TRAFFIC_PACKAGES",
|
||||
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "DISCOUNTS",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "DISCOUNTS",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
|
||||
"REFERRED_USER_REWARD": "REFERRAL",
|
||||
"DEFAULT_AUTOPAY_ENABLED": "AUTOPAY",
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
"TRIAL_WARNING_HOURS": "NOTIFICATIONS",
|
||||
"TRIAL_WARNING_HOURS": "TRIAL",
|
||||
"SUPPORT_USERNAME": "SUPPORT",
|
||||
"SUPPORT_MENU_ENABLED": "SUPPORT",
|
||||
"SUPPORT_SYSTEM_MODE": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_ENABLED": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_MINUTES": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES": "SUPPORT",
|
||||
"ADMIN_NOTIFICATIONS_ENABLED": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_NOTIFICATIONS_CHAT_ID": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_NOTIFICATIONS_TOPIC_ID": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_REPORTS_ENABLED": "ADMIN_REPORTS",
|
||||
"ADMIN_REPORTS_CHAT_ID": "ADMIN_REPORTS",
|
||||
"ADMIN_REPORTS_TOPIC_ID": "ADMIN_REPORTS",
|
||||
"ADMIN_REPORTS_SEND_TIME": "ADMIN_REPORTS",
|
||||
"PAYMENT_SERVICE_NAME": "PAYMENT",
|
||||
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
|
||||
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
|
||||
"MONITORING_INTERVAL": "MONITORING",
|
||||
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
|
||||
"LOGO_FILE": "INTERFACE_BRANDING",
|
||||
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
|
||||
@@ -153,15 +230,17 @@ class BotConfigurationService:
|
||||
"APP_CONFIG_PATH": "ADDITIONAL",
|
||||
"ENABLE_DEEP_LINKS": "ADDITIONAL",
|
||||
"APP_CONFIG_CACHE_TTL": "ADDITIONAL",
|
||||
"DEFAULT_LANGUAGE": "LOCALIZATION",
|
||||
"AVAILABLE_LANGUAGES": "LOCALIZATION",
|
||||
"PAYMENT_SERVICE_NAME": "PAYMENT",
|
||||
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"INACTIVE_USER_DELETE_MONTHS": "MONITORING",
|
||||
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
|
||||
"INACTIVE_USER_DELETE_MONTHS": "MAINTENANCE",
|
||||
"MAINTENANCE_MESSAGE": "MAINTENANCE",
|
||||
"MAINTENANCE_CHECK_INTERVAL": "MAINTENANCE",
|
||||
"MAINTENANCE_AUTO_ENABLE": "MAINTENANCE",
|
||||
"WEBHOOK_URL": "WEBHOOK",
|
||||
"WEBHOOK_SECRET": "WEBHOOK",
|
||||
"VERSION_CHECK_ENABLED": "VERSION",
|
||||
"VERSION_CHECK_REPO": "VERSION",
|
||||
"VERSION_CHECK_INTERVAL_HOURS": "VERSION",
|
||||
"TELEGRAM_STARS_RATE_RUB": "TELEGRAM",
|
||||
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE",
|
||||
}
|
||||
|
||||
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
|
||||
@@ -186,12 +265,14 @@ class BotConfigurationService:
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
|
||||
"CONNECT_BUTTON_HAPP": "HAPP",
|
||||
"HAPP_": "HAPP",
|
||||
"SKIP_": "SKIP",
|
||||
"MINIAPP_": "MINIAPP",
|
||||
"MONITORING_": "MONITORING",
|
||||
"NOTIFICATION_": "NOTIFICATIONS",
|
||||
"SERVER_STATUS": "SERVER",
|
||||
"SERVER_STATUS": "SERVER_STATUS",
|
||||
"MAINTENANCE_": "MAINTENANCE",
|
||||
"VERSION_CHECK": "VERSION",
|
||||
"BACKUP_": "BACKUP",
|
||||
@@ -288,6 +369,173 @@ class BotConfigurationService:
|
||||
],
|
||||
}
|
||||
|
||||
SETTING_HINTS: Dict[str, Dict[str, str]] = {
|
||||
"YOOKASSA_ENABLED": {
|
||||
"description": "Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.",
|
||||
"format": "Булево значение: выберите \"Включить\" или \"Выключить\".",
|
||||
"example": "Включено при полностью настроенной интеграции.",
|
||||
"warning": "При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.",
|
||||
"dependencies": "YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL",
|
||||
},
|
||||
"CRYPTOBOT_ENABLED": {
|
||||
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
|
||||
"format": "Булево значение.",
|
||||
"example": "Включите после указания токена API и секрета вебхука.",
|
||||
"warning": "Пустой токен или неверный вебхук приведут к отказам платежей.",
|
||||
"dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET",
|
||||
},
|
||||
"SUPPORT_TICKET_SLA_MINUTES": {
|
||||
"description": "Лимит времени для ответа модераторов на тикет в минутах.",
|
||||
"format": "Целое число от 1 до 1440.",
|
||||
"example": "5",
|
||||
"warning": "Слишком низкое значение может вызвать частые напоминания, слишком высокое — ухудшить SLA.",
|
||||
"dependencies": "SUPPORT_TICKET_SLA_ENABLED, SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES",
|
||||
},
|
||||
"MAINTENANCE_MODE": {
|
||||
"description": "Переводит бота в режим технического обслуживания и скрывает действия для пользователей.",
|
||||
"format": "Булево значение.",
|
||||
"example": "Включено на время плановых работ.",
|
||||
"warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.",
|
||||
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"REMNAWAVE_API_URL": {
|
||||
"description": "Базовый адрес панели RemnaWave, с которой синхронизируется бот.",
|
||||
"format": "Полный URL вида https://panel.example.com.",
|
||||
"example": "https://panel.remnawave.net",
|
||||
"warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.",
|
||||
"dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD",
|
||||
},
|
||||
"EXTERNAL_ADMIN_TOKEN": {
|
||||
"description": "Приватный токен, который использует внешняя админка для проверки запросов.",
|
||||
"format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.",
|
||||
"example": "Генерируется автоматически",
|
||||
"warning": "Токен обновится при смене username или токена бота.",
|
||||
"dependencies": "Username телеграм-бота, токен бота",
|
||||
},
|
||||
"EXTERNAL_ADMIN_TOKEN_BOT_ID": {
|
||||
"description": "Идентификатор телеграм-бота, с которым связан токен внешней админки.",
|
||||
"format": "Проставляется автоматически после первого запуска и не редактируется вручную.",
|
||||
"example": "123456789",
|
||||
"warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.",
|
||||
"dependencies": "Результат вызова getMe() в Telegram Bot API",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_category_description(cls, category_key: str) -> str:
|
||||
return cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
|
||||
|
||||
@classmethod
|
||||
def is_toggle(cls, key: str) -> bool:
|
||||
definition = cls.get_definition(key)
|
||||
return definition.python_type is bool
|
||||
|
||||
@classmethod
|
||||
def is_read_only(cls, key: str) -> bool:
|
||||
return key in cls.READ_ONLY_KEYS
|
||||
|
||||
@classmethod
|
||||
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
upper_key = key.upper()
|
||||
if any(suffix in upper_key for suffix in ("PRICE", "_KOPEKS", "AMOUNT")):
|
||||
try:
|
||||
return settings.format_price(int(value))
|
||||
except Exception:
|
||||
return f"{value}"
|
||||
if upper_key.endswith("_PERCENT") or "PERCENT" in upper_key:
|
||||
return f"{value}%"
|
||||
if upper_key.endswith("_HOURS"):
|
||||
return f"{value} ч"
|
||||
if upper_key.endswith("_MINUTES"):
|
||||
return f"{value} мин"
|
||||
if upper_key.endswith("_SECONDS"):
|
||||
return f"{value} сек"
|
||||
if upper_key.endswith("_DAYS"):
|
||||
return f"{value} дн"
|
||||
if upper_key.endswith("_GB"):
|
||||
return f"{value} ГБ"
|
||||
if upper_key.endswith("_MB"):
|
||||
return f"{value} МБ"
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _split_comma_values(cls, text: str) -> Optional[List[str]]:
|
||||
raw = (text or "").strip()
|
||||
if not raw or "," not in raw:
|
||||
return None
|
||||
parts = [segment.strip() for segment in raw.split(",") if segment.strip()]
|
||||
return parts or None
|
||||
|
||||
@classmethod
|
||||
def format_value_human(cls, key: str, value: Any) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
if isinstance(value, bool):
|
||||
return "✅ ВКЛЮЧЕНО" if value else "❌ ВЫКЛЮЧЕНО"
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
formatted = cls._format_numeric_with_unit(key, value)
|
||||
return formatted or str(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
return "—"
|
||||
if key in cls.PLAIN_TEXT_KEYS:
|
||||
return cleaned
|
||||
if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")):
|
||||
return "••••••••"
|
||||
items = cls._split_comma_values(cleaned)
|
||||
if items:
|
||||
return ", ".join(items)
|
||||
return cleaned
|
||||
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return ", ".join(str(item) for item in value)
|
||||
|
||||
if isinstance(value, dict):
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_setting_guidance(cls, key: str) -> Dict[str, str]:
|
||||
definition = cls.get_definition(key)
|
||||
original = cls.get_original_value(key)
|
||||
type_label = definition.type_label
|
||||
hints = dict(cls.SETTING_HINTS.get(key, {}))
|
||||
|
||||
base_description = (
|
||||
hints.get("description")
|
||||
or f"Параметр <b>{definition.display_name}</b> управляет категорией «{definition.category_label}»."
|
||||
)
|
||||
base_format = hints.get("format") or (
|
||||
"Булево значение (да/нет)." if definition.python_type is bool
|
||||
else "Введите значение соответствующего типа (число или строку)."
|
||||
)
|
||||
example = hints.get("example") or (
|
||||
cls.format_value_human(key, original) if original is not None else "—"
|
||||
)
|
||||
warning = hints.get("warning") or (
|
||||
"Неверные значения могут привести к некорректной работе бота."
|
||||
)
|
||||
dependencies = hints.get("dependencies") or definition.category_label
|
||||
|
||||
return {
|
||||
"description": base_description,
|
||||
"format": base_format,
|
||||
"example": example,
|
||||
"warning": warning,
|
||||
"dependencies": dependencies,
|
||||
"type": type_label,
|
||||
}
|
||||
|
||||
_definitions: Dict[str, SettingDefinition] = {}
|
||||
_original_values: Dict[str, Any] = settings.model_dump()
|
||||
_overrides_raw: Dict[str, Optional[str]] = {}
|
||||
@@ -441,7 +689,7 @@ class BotConfigurationService:
|
||||
@classmethod
|
||||
def format_value_for_list(cls, key: str) -> str:
|
||||
value = cls.get_current_value(key)
|
||||
formatted = cls.format_value(value)
|
||||
formatted = cls.format_value_human(key, value)
|
||||
if formatted == "—":
|
||||
return formatted
|
||||
return _truncate(formatted)
|
||||
@@ -643,7 +891,17 @@ class BotConfigurationService:
|
||||
return parsed_value
|
||||
|
||||
@classmethod
|
||||
async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None:
|
||||
async def set_value(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
value: Any,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if cls.is_read_only(key) and not force:
|
||||
raise ReadOnlySettingError(f"Setting {key} is read-only")
|
||||
|
||||
raw_value = cls.serialize_value(key, value)
|
||||
await upsert_system_setting(db, key, raw_value)
|
||||
cls._overrides_raw[key] = raw_value
|
||||
@@ -653,7 +911,16 @@ class BotConfigurationService:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
async def reset_value(cls, db: AsyncSession, key: str) -> None:
|
||||
async def reset_value(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if cls.is_read_only(key) and not force:
|
||||
raise ReadOnlySettingError(f"Setting {key} is read-only")
|
||||
|
||||
await delete_system_setting(db, key)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
original = cls.get_original_value(key)
|
||||
@@ -666,6 +933,17 @@ class BotConfigurationService:
|
||||
def _apply_to_settings(cls, key: str, value: Any) -> None:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
if key in {
|
||||
"PRICE_14_DAYS",
|
||||
"PRICE_30_DAYS",
|
||||
"PRICE_60_DAYS",
|
||||
"PRICE_90_DAYS",
|
||||
"PRICE_180_DAYS",
|
||||
"PRICE_360_DAYS",
|
||||
}:
|
||||
refresh_period_prices()
|
||||
elif key.startswith("PRICE_TRAFFIC_") or key == "TRAFFIC_PACKAGES_CONFIG":
|
||||
refresh_traffic_prices()
|
||||
except Exception as error:
|
||||
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
|
||||
|
||||
@@ -691,12 +969,13 @@ class BotConfigurationService:
|
||||
return {
|
||||
"key": key,
|
||||
"name": definition.display_name,
|
||||
"current": cls.format_value(current),
|
||||
"original": cls.format_value(original),
|
||||
"current": cls.format_value_human(key, current),
|
||||
"original": cls.format_value_human(key, original),
|
||||
"type": definition.type_label,
|
||||
"category_key": definition.category_key,
|
||||
"category_label": definition.category_label,
|
||||
"has_override": has_override,
|
||||
"is_read_only": cls.is_read_only(key),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,23 +2,28 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy import delete, select, update, func
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user,
|
||||
get_users_spending_stats
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import (
|
||||
get_subscription_by_user_id,
|
||||
decrement_subscription_server_counts,
|
||||
)
|
||||
from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
|
||||
AdvertisingCampaign, PaymentMethod
|
||||
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
||||
TransactionType
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -141,20 +146,32 @@ class UserService:
|
||||
"has_next": False,
|
||||
"has_prev": False
|
||||
}
|
||||
|
||||
|
||||
async def get_users_page(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
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
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
users = await get_users_list(
|
||||
db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
status=status,
|
||||
order_by_balance=order_by_balance,
|
||||
order_by_traffic=order_by_traffic,
|
||||
order_by_last_activity=order_by_last_activity,
|
||||
order_by_total_spent=order_by_total_spent,
|
||||
order_by_purchase_count=order_by_purchase_count,
|
||||
)
|
||||
total_count = await get_users_count(db, status=status)
|
||||
|
||||
@@ -179,7 +196,110 @@ class UserService:
|
||||
"has_next": False,
|
||||
"has_prev": False
|
||||
}
|
||||
|
||||
|
||||
async def get_user_spending_stats_map(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_ids: List[int]
|
||||
) -> Dict[int, Dict[str, int]]:
|
||||
try:
|
||||
return await get_users_spending_stats(db, user_ids)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики трат пользователей: {e}")
|
||||
return {}
|
||||
|
||||
async def get_users_by_campaign_page(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 20
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
campaign_ranked = (
|
||||
select(
|
||||
AdvertisingCampaignRegistration.user_id.label("user_id"),
|
||||
AdvertisingCampaignRegistration.campaign_id.label("campaign_id"),
|
||||
AdvertisingCampaignRegistration.created_at.label("created_at"),
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=AdvertisingCampaignRegistration.user_id,
|
||||
order_by=AdvertisingCampaignRegistration.created_at.desc(),
|
||||
)
|
||||
.label("rn"),
|
||||
)
|
||||
.cte("campaign_ranked")
|
||||
)
|
||||
|
||||
latest_campaign = (
|
||||
select(
|
||||
campaign_ranked.c.user_id,
|
||||
campaign_ranked.c.campaign_id,
|
||||
campaign_ranked.c.created_at,
|
||||
)
|
||||
.where(campaign_ranked.c.rn == 1)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
AdvertisingCampaign.name.label("campaign_name"),
|
||||
latest_campaign.c.created_at,
|
||||
)
|
||||
.join(latest_campaign, latest_campaign.c.user_id == User.id)
|
||||
.join(
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaign.id == latest_campaign.c.campaign_id,
|
||||
)
|
||||
.order_by(
|
||||
AdvertisingCampaign.name.asc(),
|
||||
latest_campaign.c.created_at.desc(),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
users = [row[0] for row in rows]
|
||||
campaign_map = {
|
||||
row[0].id: {
|
||||
"campaign_name": row[1],
|
||||
"registered_at": row[2],
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
total_stmt = select(func.count()).select_from(latest_campaign)
|
||||
total_result = await db.execute(total_stmt)
|
||||
total_count = total_result.scalar() or 0
|
||||
total_pages = (total_count + limit - 1) // limit if total_count else 1
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"campaigns": campaign_map,
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"total_count": total_count,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения пользователей по кампаниям: {e}")
|
||||
return {
|
||||
"users": [],
|
||||
"campaigns": {},
|
||||
"current_page": 1,
|
||||
"total_pages": 1,
|
||||
"total_count": 0,
|
||||
"has_next": False,
|
||||
"has_prev": False,
|
||||
}
|
||||
|
||||
async def update_user_balance(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -417,7 +537,13 @@ class UserService:
|
||||
)
|
||||
)
|
||||
subscription_servers = subscription_servers_result.scalars().all()
|
||||
|
||||
|
||||
await decrement_subscription_server_counts(
|
||||
db,
|
||||
user.subscription,
|
||||
subscription_servers=subscription_servers,
|
||||
)
|
||||
|
||||
if subscription_servers:
|
||||
logger.info(f"🔄 Удаляем {len(subscription_servers)} связей подписка-сервер")
|
||||
await db.execute(
|
||||
|
||||
+29
-2
@@ -87,6 +87,12 @@ class AdminStates(StatesGroup):
|
||||
editing_user_traffic = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
editing_privacy_policy = State()
|
||||
editing_public_offer = State()
|
||||
creating_faq_title = State()
|
||||
creating_faq_content = State()
|
||||
editing_faq_title = State()
|
||||
editing_faq_content = State()
|
||||
editing_notification_value = State()
|
||||
|
||||
confirming_sync = State()
|
||||
@@ -97,17 +103,32 @@ class AdminStates(StatesGroup):
|
||||
editing_server_limit = State()
|
||||
editing_server_description = State()
|
||||
editing_server_promo_groups = State()
|
||||
|
||||
|
||||
creating_server_uuid = State()
|
||||
creating_server_name = State()
|
||||
creating_server_price = State()
|
||||
creating_server_country = State()
|
||||
|
||||
|
||||
editing_welcome_text = State()
|
||||
waiting_for_message_buttons = "waiting_for_message_buttons"
|
||||
|
||||
editing_promo_offer_message = State()
|
||||
editing_promo_offer_button = State()
|
||||
editing_promo_offer_valid_hours = State()
|
||||
editing_promo_offer_active_duration = State()
|
||||
editing_promo_offer_discount = State()
|
||||
editing_promo_offer_test_duration = State()
|
||||
editing_promo_offer_squads = State()
|
||||
selecting_promo_offer_user = State()
|
||||
searching_promo_offer_user = State()
|
||||
|
||||
# Состояния для отслеживания источника перехода
|
||||
viewing_user_from_balance_list = State()
|
||||
viewing_user_from_traffic_list = State()
|
||||
viewing_user_from_last_activity_list = State()
|
||||
viewing_user_from_spending_list = State()
|
||||
viewing_user_from_purchases_list = State()
|
||||
viewing_user_from_campaign_list = State()
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
@@ -127,6 +148,12 @@ class SupportSettingsStates(StatesGroup):
|
||||
|
||||
class BotConfigStates(StatesGroup):
|
||||
waiting_for_value = State()
|
||||
waiting_for_search_query = State()
|
||||
waiting_for_import_file = State()
|
||||
|
||||
|
||||
class PricingStates(StatesGroup):
|
||||
waiting_for_value = State()
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
|
||||
+44
-25
@@ -28,7 +28,7 @@ def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str:
|
||||
return dt.strftime(format_str)
|
||||
|
||||
|
||||
def format_time_ago(dt: Union[datetime, str]) -> str:
|
||||
def format_time_ago(dt: Union[datetime, str], language: str = "ru") -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
@@ -40,32 +40,51 @@ def format_time_ago(dt: Union[datetime, str]) -> str:
|
||||
|
||||
now = datetime.utcnow()
|
||||
diff = now - dt
|
||||
|
||||
|
||||
language_code = (language or "ru").split("-")[0].lower()
|
||||
|
||||
if diff.days > 0:
|
||||
if diff.days == 1:
|
||||
return "вчера"
|
||||
elif diff.days < 7:
|
||||
return f"{diff.days} дн. назад"
|
||||
elif diff.days < 30:
|
||||
weeks = diff.days // 7
|
||||
return f"{weeks} нед. назад"
|
||||
elif diff.days < 365:
|
||||
months = diff.days // 30
|
||||
return f"{months} мес. назад"
|
||||
else:
|
||||
years = diff.days // 365
|
||||
return f"{years} г. назад"
|
||||
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} ч. назад"
|
||||
|
||||
elif diff.seconds > 60:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} мин. назад"
|
||||
|
||||
else:
|
||||
return "только что"
|
||||
return "yesterday" if language_code == "en" else "вчера"
|
||||
if diff.days < 7:
|
||||
value = diff.days
|
||||
if language_code == "en":
|
||||
suffix = "day" if value == 1 else "days"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} дн. назад"
|
||||
if diff.days < 30:
|
||||
value = diff.days // 7
|
||||
if language_code == "en":
|
||||
suffix = "week" if value == 1 else "weeks"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} нед. назад"
|
||||
if diff.days < 365:
|
||||
value = diff.days // 30
|
||||
if language_code == "en":
|
||||
suffix = "month" if value == 1 else "months"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} мес. назад"
|
||||
value = diff.days // 365
|
||||
if language_code == "en":
|
||||
suffix = "year" if value == 1 else "years"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} г. назад"
|
||||
|
||||
if diff.seconds > 3600:
|
||||
value = diff.seconds // 3600
|
||||
if language_code == "en":
|
||||
suffix = "hour" if value == 1 else "hours"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} ч. назад"
|
||||
|
||||
if diff.seconds > 60:
|
||||
value = diff.seconds // 60
|
||||
if language_code == "en":
|
||||
suffix = "minute" if value == 1 else "minutes"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} мин. назад"
|
||||
|
||||
return "just now" if language_code == "en" else "только что"
|
||||
|
||||
def format_days_declension(days: int, language: str = "ru") -> str:
|
||||
if language != "ru":
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from pathlib import Path
|
||||
from aiogram.types import Message, FSInputFile, InputMediaPhoto
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import FSInputFile, InputMediaPhoto, Message
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
LOGO_PATH = Path(settings.LOGO_FILE)
|
||||
_PRIVACY_RESTRICTED_CODE = "BUTTON_USER_PRIVACY_RESTRICTED"
|
||||
|
||||
|
||||
def is_qr_message(message: Message) -> bool:
|
||||
@@ -15,6 +19,65 @@ _original_answer = Message.answer
|
||||
_original_edit_text = Message.edit_text
|
||||
|
||||
|
||||
def _get_language(message: Message) -> str | None:
|
||||
try:
|
||||
user = message.from_user
|
||||
if user and getattr(user, "language_code", None):
|
||||
return user.language_code
|
||||
except AttributeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _default_privacy_hint(language: str | None) -> str:
|
||||
if language and language.lower().startswith("en"):
|
||||
return (
|
||||
"⚠️ Telegram blocked the contact request button because of your privacy settings. "
|
||||
"Please allow sharing your contact information or send the required details manually."
|
||||
)
|
||||
return (
|
||||
"⚠️ Telegram запретил кнопку запроса контакта из-за настроек приватности. "
|
||||
"Разрешите отправку контакта в настройках Telegram или отправьте данные вручную."
|
||||
)
|
||||
|
||||
|
||||
def append_privacy_hint(text: str | None, language: str | None) -> str:
|
||||
base_text = text or ""
|
||||
try:
|
||||
hint = get_texts(language).t(
|
||||
"PRIVACY_RESTRICTED_BUTTON_HINT",
|
||||
default=_default_privacy_hint(language),
|
||||
)
|
||||
except Exception:
|
||||
hint = _default_privacy_hint(language)
|
||||
|
||||
hint = hint.strip()
|
||||
if not hint:
|
||||
return base_text
|
||||
|
||||
if hint in base_text:
|
||||
return base_text
|
||||
|
||||
if base_text:
|
||||
return f"{base_text}\n\n{hint}"
|
||||
return hint
|
||||
|
||||
|
||||
def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
||||
safe_kwargs: Dict[str, Any] = dict(kwargs or {})
|
||||
safe_kwargs.pop("reply_markup", None)
|
||||
return safe_kwargs
|
||||
|
||||
|
||||
def is_privacy_restricted_error(error: Exception) -> bool:
|
||||
if not isinstance(error, TelegramBadRequest):
|
||||
return False
|
||||
|
||||
message = getattr(error, "message", "") or ""
|
||||
description = str(error)
|
||||
return _PRIVACY_RESTRICTED_CODE in message or _PRIVACY_RESTRICTED_CODE in description
|
||||
|
||||
|
||||
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
@@ -25,12 +88,20 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
language = _get_language(self)
|
||||
|
||||
if LOGO_PATH.exists():
|
||||
try:
|
||||
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_privacy_restricted_error(error):
|
||||
fallback_text = append_privacy_hint(text, language)
|
||||
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
|
||||
return await _original_answer(self, fallback_text, **safe_kwargs)
|
||||
# Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
# Фоллбек, если Telegram ругается на caption: отправим как текст
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
|
||||
@@ -40,6 +111,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
if self.photo:
|
||||
language = _get_language(self)
|
||||
# Если caption потенциально слишком длинный — отправим как текст вместо caption
|
||||
try:
|
||||
if text is not None and len(text) > 900:
|
||||
@@ -59,14 +131,23 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
else:
|
||||
media = self.photo[-1].file_id
|
||||
media_kwargs = {"media": media, "caption": text}
|
||||
if "parse_mode" in kwargs:
|
||||
_pm = kwargs.pop("parse_mode")
|
||||
edit_kwargs = dict(kwargs)
|
||||
if "parse_mode" in edit_kwargs:
|
||||
_pm = edit_kwargs.pop("parse_mode")
|
||||
media_kwargs["parse_mode"] = _pm if _pm is not None else "HTML"
|
||||
else:
|
||||
media_kwargs["parse_mode"] = "HTML"
|
||||
try:
|
||||
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
|
||||
except TelegramBadRequest:
|
||||
return await self.edit_media(InputMediaPhoto(**media_kwargs), **edit_kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_privacy_restricted_error(error):
|
||||
fallback_text = append_privacy_hint(text, language)
|
||||
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
|
||||
try:
|
||||
await self.delete()
|
||||
except Exception:
|
||||
pass
|
||||
return await _original_answer(self, fallback_text, **safe_kwargs)
|
||||
# Фоллбек: удалим и отправим обычный текст без фото
|
||||
try:
|
||||
await self.delete()
|
||||
|
||||
+68
-27
@@ -3,7 +3,13 @@ from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import FSInputFile, InputMediaPhoto
|
||||
|
||||
from app.config import settings
|
||||
from .message_patch import LOGO_PATH, is_qr_message
|
||||
from .message_patch import (
|
||||
LOGO_PATH,
|
||||
append_privacy_hint,
|
||||
is_privacy_restricted_error,
|
||||
is_qr_message,
|
||||
prepare_privacy_safe_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_media(message: types.Message):
|
||||
@@ -17,35 +23,69 @@ def _resolve_media(message: types.Message):
|
||||
return FSInputFile(LOGO_PATH)
|
||||
|
||||
|
||||
def _get_language(callback: types.CallbackQuery) -> str | None:
|
||||
try:
|
||||
user = callback.from_user
|
||||
if user and getattr(user, "language_code", None):
|
||||
return user.language_code
|
||||
except AttributeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None):
|
||||
kwargs: dict[str, object] = {}
|
||||
if parse_mode is not None:
|
||||
kwargs["parse_mode"] = parse_mode
|
||||
if keyboard is not None:
|
||||
kwargs["reply_markup"] = keyboard
|
||||
return kwargs
|
||||
|
||||
|
||||
async def _answer_text(
|
||||
callback: types.CallbackQuery,
|
||||
caption: str,
|
||||
keyboard: types.InlineKeyboardMarkup | None,
|
||||
parse_mode: str | None,
|
||||
error: TelegramBadRequest | None = None,
|
||||
) -> None:
|
||||
language = _get_language(callback)
|
||||
kwargs = _build_base_kwargs(keyboard, parse_mode)
|
||||
|
||||
if error and is_privacy_restricted_error(error):
|
||||
caption = append_privacy_hint(caption, language)
|
||||
kwargs = prepare_privacy_safe_kwargs(kwargs)
|
||||
|
||||
kwargs.setdefault("parse_mode", parse_mode or "HTML")
|
||||
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def edit_or_answer_photo(
|
||||
callback: types.CallbackQuery,
|
||||
caption: str,
|
||||
keyboard: types.InlineKeyboardMarkup,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> None:
|
||||
resolved_parse_mode = parse_mode or "HTML"
|
||||
# Если режим логотипа выключен — работаем текстом
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
except TelegramBadRequest as error:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
|
||||
return
|
||||
|
||||
# Если текст слишком длинный для caption — отправим как текст
|
||||
@@ -53,13 +93,9 @@ async def edit_or_answer_photo(
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
|
||||
except TelegramBadRequest as error:
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
|
||||
return
|
||||
|
||||
media = _resolve_media(callback.message)
|
||||
@@ -68,7 +104,14 @@ async def edit_or_answer_photo(
|
||||
InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or "HTML")),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
except TelegramBadRequest as error:
|
||||
if is_privacy_restricted_error(error):
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
|
||||
return
|
||||
# Фоллбек: если не удалось обновить фото — отправим текст, чтобы не упасть на лимите caption
|
||||
try:
|
||||
await callback.message.delete()
|
||||
@@ -80,12 +123,10 @@ async def edit_or_answer_photo(
|
||||
photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=(parse_mode or "HTML"),
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
except TelegramBadRequest as photo_error:
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, photo_error)
|
||||
except Exception:
|
||||
# Последний фоллбек — обычный текст
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=(parse_mode or "HTML"),
|
||||
)
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
|
||||
from app.database.models import ServerSquad, SubscriptionTemporaryAccess, User
|
||||
|
||||
|
||||
def _escape_format_braces(text: str) -> str:
|
||||
"""Escape braces so str.format treats them as literals."""
|
||||
|
||||
return text.replace("{", "{{").replace("}", "}}")
|
||||
|
||||
|
||||
def get_user_active_promo_discount_percent(user: Optional[User]) -> int:
|
||||
if not user:
|
||||
return 0
|
||||
|
||||
try:
|
||||
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||||
if expires_at and expires_at <= datetime.utcnow():
|
||||
return 0
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
def _format_time_left(seconds_left: int, language: str) -> str:
|
||||
total_minutes = max(1, math.ceil(seconds_left / 60))
|
||||
days, remainder_minutes = divmod(total_minutes, 60 * 24)
|
||||
hours, minutes = divmod(remainder_minutes, 60)
|
||||
|
||||
language_code = (language or "ru").split("-")[0].lower()
|
||||
if language_code == "en":
|
||||
day_label, hour_label, minute_label = "d", "h", "m"
|
||||
else:
|
||||
day_label, hour_label, minute_label = "д", "ч", "м"
|
||||
|
||||
parts: list[str] = []
|
||||
if days:
|
||||
parts.append(f"{days}{day_label}")
|
||||
if hours or days:
|
||||
parts.append(f"{hours}{hour_label}")
|
||||
parts.append(f"{minutes}{minute_label}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _build_progress_bar(seconds_left: int, total_seconds: int) -> str:
|
||||
if total_seconds <= 0:
|
||||
total_seconds = seconds_left or 1
|
||||
|
||||
ratio = max(0.0, min(1.0, seconds_left / total_seconds))
|
||||
bar_length = 10
|
||||
filled_segments = int(round(ratio * bar_length))
|
||||
filled_segments = max(0, min(bar_length, filled_segments))
|
||||
if filled_segments == 0 and seconds_left > 0:
|
||||
filled_segments = 1
|
||||
|
||||
return f"[{'█' * filled_segments}{'░' * (bar_length - filled_segments)}]"
|
||||
|
||||
|
||||
async def build_promo_offer_timer_line(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
texts,
|
||||
) -> Optional[str]:
|
||||
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||||
if not expires_at:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
if expires_at <= now:
|
||||
return None
|
||||
|
||||
seconds_left = int((expires_at - now).total_seconds())
|
||||
if seconds_left <= 0:
|
||||
return None
|
||||
|
||||
total_seconds: Optional[int] = None
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception:
|
||||
offer = None
|
||||
|
||||
if offer and getattr(offer, "claimed_at", None):
|
||||
total_seconds = int((expires_at - offer.claimed_at).total_seconds())
|
||||
if total_seconds <= 0:
|
||||
total_seconds = None
|
||||
|
||||
if total_seconds is None and offer:
|
||||
extra_data = getattr(offer, "extra_data", None)
|
||||
if isinstance(extra_data, dict):
|
||||
raw_duration = (
|
||||
extra_data.get("active_discount_hours")
|
||||
or extra_data.get("duration_hours")
|
||||
)
|
||||
else:
|
||||
raw_duration = None
|
||||
try:
|
||||
if raw_duration:
|
||||
total_seconds = int(float(raw_duration) * 3600)
|
||||
except (TypeError, ValueError):
|
||||
total_seconds = None
|
||||
|
||||
if total_seconds is None or total_seconds <= 0:
|
||||
total_seconds = seconds_left
|
||||
|
||||
bar = _build_progress_bar(seconds_left, total_seconds)
|
||||
time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru"))
|
||||
|
||||
template = texts.t(
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER",
|
||||
"⏳ Discount active for {time_left}\n<code>{bar}</code>",
|
||||
)
|
||||
return template.format(bar=bar, time_left=time_left_text)
|
||||
|
||||
|
||||
async def build_promo_offer_hint(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
texts,
|
||||
percent: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
if percent is None:
|
||||
percent = get_user_active_promo_discount_percent(user)
|
||||
|
||||
if percent <= 0:
|
||||
return None
|
||||
|
||||
base_hint = texts.t(
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT",
|
||||
"⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
|
||||
).format(percent=percent)
|
||||
|
||||
timer_line = await build_promo_offer_timer_line(db, user, texts)
|
||||
if timer_line:
|
||||
return f"{base_hint}\n{timer_line}"
|
||||
|
||||
return base_hint
|
||||
|
||||
|
||||
async def build_test_access_hint(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
texts,
|
||||
) -> Optional[str]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
subscription_id = getattr(subscription, "id", None)
|
||||
if not subscription_id:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.options(selectinload(SubscriptionTemporaryAccess.offer))
|
||||
.where(
|
||||
SubscriptionTemporaryAccess.subscription_id == subscription_id,
|
||||
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
|
||||
SubscriptionTemporaryAccess.expires_at > now,
|
||||
)
|
||||
.order_by(SubscriptionTemporaryAccess.expires_at.desc())
|
||||
)
|
||||
entries: Sequence[SubscriptionTemporaryAccess] = result.scalars().all()
|
||||
|
||||
active_entries = [
|
||||
entry for entry in entries if entry.expires_at and entry.expires_at > now
|
||||
]
|
||||
if not active_entries:
|
||||
return None
|
||||
|
||||
latest_expiry = max(entry.expires_at for entry in active_entries)
|
||||
seconds_left = int((latest_expiry - now).total_seconds())
|
||||
if seconds_left <= 0:
|
||||
return None
|
||||
|
||||
total_seconds: Optional[int] = None
|
||||
for entry in active_entries:
|
||||
offer = entry.offer
|
||||
claimed_at = getattr(offer, "claimed_at", None) if offer else None
|
||||
if claimed_at:
|
||||
total = int((entry.expires_at - claimed_at).total_seconds())
|
||||
if total > 0 and (total_seconds is None or total > total_seconds):
|
||||
total_seconds = total
|
||||
|
||||
if total_seconds is None or total_seconds <= 0:
|
||||
total_seconds = seconds_left
|
||||
|
||||
bar = _build_progress_bar(seconds_left, total_seconds)
|
||||
time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru"))
|
||||
|
||||
unique_squad_uuids: list[str] = []
|
||||
seen_squads: set[str] = set()
|
||||
for entry in active_entries:
|
||||
squad_uuid = getattr(entry, "squad_uuid", None)
|
||||
if squad_uuid and squad_uuid not in seen_squads:
|
||||
seen_squads.add(squad_uuid)
|
||||
unique_squad_uuids.append(squad_uuid)
|
||||
|
||||
squad_display_names: list[str] = []
|
||||
if unique_squad_uuids:
|
||||
squads_result = await db.execute(
|
||||
select(ServerSquad.squad_uuid, ServerSquad.display_name).where(
|
||||
ServerSquad.squad_uuid.in_(unique_squad_uuids)
|
||||
)
|
||||
)
|
||||
names_map = {
|
||||
squad_uuid: html.escape(display_name)
|
||||
for squad_uuid, display_name in squads_result.all()
|
||||
if display_name
|
||||
}
|
||||
for squad_uuid in unique_squad_uuids:
|
||||
if squad_uuid in names_map:
|
||||
squad_display_names.append(names_map[squad_uuid])
|
||||
else:
|
||||
squad_display_names.append(html.escape(squad_uuid))
|
||||
|
||||
if squad_display_names:
|
||||
servers_display = ", ".join(squad_display_names)
|
||||
elif unique_squad_uuids:
|
||||
servers_display = ", ".join(
|
||||
html.escape(squad_uuid) for squad_uuid in unique_squad_uuids
|
||||
)
|
||||
else:
|
||||
servers_display = str(len(active_entries))
|
||||
|
||||
header_template = texts.t(
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER",
|
||||
"🧪 Test servers active: {servers}",
|
||||
)
|
||||
timer_template = texts.t(
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER",
|
||||
"⏳ Access active for {time_left}\n<code>{bar}</code>",
|
||||
)
|
||||
|
||||
header = header_template.format(
|
||||
servers=_escape_format_braces(servers_display)
|
||||
)
|
||||
timer_line = timer_template.format(time_left=time_left_text, bar=bar)
|
||||
|
||||
return f"{header}\n{timer_line}"
|
||||
@@ -0,0 +1,184 @@
|
||||
import platform
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepRecord:
|
||||
title: str
|
||||
icon: str
|
||||
status_label: str
|
||||
message: str
|
||||
duration: float
|
||||
|
||||
|
||||
class StageHandle:
|
||||
def __init__(
|
||||
self,
|
||||
timeline: "StartupTimeline",
|
||||
title: str,
|
||||
icon: str,
|
||||
success_message: Optional[str],
|
||||
) -> None:
|
||||
self.timeline = timeline
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.message = success_message or ""
|
||||
self.status_icon = "✅"
|
||||
self.status_label = "Готово"
|
||||
self._explicit_status = False
|
||||
|
||||
def success(self, message: Optional[str] = None) -> None:
|
||||
if message is not None:
|
||||
self.message = message
|
||||
self.status_icon = "✅"
|
||||
self.status_label = "Готово"
|
||||
self._explicit_status = True
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
self.status_icon = "⚠️"
|
||||
self.status_label = "Предупреждение"
|
||||
self.message = message
|
||||
self._explicit_status = True
|
||||
|
||||
def skip(self, message: str) -> None:
|
||||
self.status_icon = "⏭️"
|
||||
self.status_label = "Пропущено"
|
||||
self.message = message
|
||||
self._explicit_status = True
|
||||
|
||||
def failure(self, message: str) -> None:
|
||||
self.status_icon = "❌"
|
||||
self.status_label = "Ошибка"
|
||||
self.message = message
|
||||
self._explicit_status = True
|
||||
|
||||
def log(self, message: str, icon: str = "•") -> None:
|
||||
self.timeline.logger.info(f"┃ {icon} {message}")
|
||||
|
||||
|
||||
class StartupTimeline:
|
||||
def __init__(self, logger: Any, app_name: str) -> None:
|
||||
self.logger = logger
|
||||
self.app_name = app_name
|
||||
self.steps: List[StepRecord] = []
|
||||
|
||||
def _record_step(
|
||||
self, title: str, icon: str, status_label: str, message: str, duration: float
|
||||
) -> None:
|
||||
self.steps.append(
|
||||
StepRecord(
|
||||
title=title,
|
||||
icon=icon,
|
||||
status_label=status_label,
|
||||
message=message,
|
||||
duration=duration,
|
||||
)
|
||||
)
|
||||
|
||||
def log_banner(self, metadata: Optional[Sequence[Tuple[str, Any]]] = None) -> None:
|
||||
title_text = f"🚀 {self.app_name}"
|
||||
subtitle_parts = [f"Python {platform.python_version()}"]
|
||||
if metadata:
|
||||
for key, value in metadata:
|
||||
subtitle_parts.append(f"{key}: {value}")
|
||||
subtitle_text = " | ".join(subtitle_parts)
|
||||
|
||||
width = max(len(title_text), len(subtitle_text))
|
||||
border = "╔" + "═" * (width + 2) + "╗"
|
||||
self.logger.info(border)
|
||||
self.logger.info("║ " + title_text.ljust(width) + " ║")
|
||||
self.logger.info("║ " + subtitle_text.ljust(width) + " ║")
|
||||
self.logger.info("╚" + "═" * (width + 2) + "╝")
|
||||
|
||||
def log_section(self, title: str, lines: Iterable[str], icon: str = "📄") -> None:
|
||||
items = [f"{icon} {title}"] + [f"• {line}" for line in lines]
|
||||
width = max(len(item) for item in items)
|
||||
top = "┌ " + "─" * width + " ┐"
|
||||
middle = "├ " + "─" * width + " ┤"
|
||||
bottom = "└ " + "─" * width + " ┘"
|
||||
|
||||
self.logger.info(top)
|
||||
self.logger.info("│ " + items[0].ljust(width) + " │")
|
||||
self.logger.info(middle)
|
||||
for item in items[1:]:
|
||||
self.logger.info("│ " + item.ljust(width) + " │")
|
||||
self.logger.info(bottom)
|
||||
|
||||
def add_manual_step(
|
||||
self,
|
||||
title: str,
|
||||
icon: str,
|
||||
status_label: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
self.logger.info(f"┏ {icon} {title}")
|
||||
self.logger.info(f"┗ {icon} {title} — {status_label}: {message}")
|
||||
self._record_step(title, icon, status_label, message, 0.0)
|
||||
|
||||
@asynccontextmanager
|
||||
async def stage(
|
||||
self,
|
||||
title: str,
|
||||
icon: str = "⚙️",
|
||||
description: Optional[str] = None,
|
||||
success_message: Optional[str] = "Готово",
|
||||
):
|
||||
if description:
|
||||
self.logger.info(f"┏ {icon} {title} — {description}")
|
||||
else:
|
||||
self.logger.info(f"┏ {icon} {title}")
|
||||
|
||||
handle = StageHandle(self, title, icon, success_message)
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
yield handle
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
handle.failure(message)
|
||||
self.logger.exception(f"┣ ❌ {title} — ошибка: {message}")
|
||||
raise
|
||||
finally:
|
||||
duration = time.perf_counter() - start_time
|
||||
if not handle._explicit_status:
|
||||
handle.success(handle.message or "Готово")
|
||||
self.logger.info(
|
||||
f"┗ {handle.status_icon} {title} — {handle.message} [{duration:.2f}s]"
|
||||
)
|
||||
self._record_step(
|
||||
title=title,
|
||||
icon=handle.status_icon,
|
||||
status_label=handle.status_label,
|
||||
message=handle.message,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
def log_summary(self) -> None:
|
||||
if not self.steps:
|
||||
return
|
||||
|
||||
lines = []
|
||||
for step in self.steps:
|
||||
base = (
|
||||
f"{step.icon} {step.title} — {step.status_label}"
|
||||
f" [{step.duration:.2f}s]"
|
||||
)
|
||||
if step.message:
|
||||
base += f" :: {step.message}"
|
||||
lines.append(base)
|
||||
|
||||
width = max(len(line) for line in lines)
|
||||
border_top = "┏" + "━" * (width + 2) + "┓"
|
||||
border_mid = "┣" + "━" * (width + 2) + "┫"
|
||||
border_bottom = "┗" + "━" * (width + 2) + "┛"
|
||||
title = "РЕЗЮМЕ ЗАПУСКА"
|
||||
|
||||
self.logger.info(border_top)
|
||||
self.logger.info("┃ " + title.center(width) + " ┃")
|
||||
self.logger.info(border_mid)
|
||||
for line in lines:
|
||||
self.logger.info("┃ " + line.ljust(width) + " ┃")
|
||||
self.logger.info(border_bottom)
|
||||
|
||||
@@ -48,18 +48,36 @@ async def update_or_create_subscription(
|
||||
for key, value in subscription_data.items():
|
||||
if hasattr(existing_subscription, key):
|
||||
setattr(existing_subscription, key, value)
|
||||
|
||||
|
||||
existing_subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
|
||||
logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}")
|
||||
return existing_subscription
|
||||
|
||||
|
||||
else:
|
||||
subscription_defaults = dict(subscription_data)
|
||||
autopay_enabled = subscription_defaults.pop(
|
||||
"autopay_enabled", None
|
||||
)
|
||||
autopay_days_before = subscription_defaults.pop(
|
||||
"autopay_days_before", None
|
||||
)
|
||||
|
||||
new_subscription = Subscription(
|
||||
user_id=user_id,
|
||||
**subscription_data
|
||||
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
|
||||
),
|
||||
**subscription_defaults
|
||||
)
|
||||
|
||||
db.add(new_subscription)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Utilities for validating Telegram WebApp initialization data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
|
||||
class TelegramWebAppAuthError(Exception):
|
||||
"""Raised when Telegram WebApp init data fails validation."""
|
||||
|
||||
|
||||
def parse_webapp_init_data(
|
||||
init_data: str,
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age_seconds: int = 86400,
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate and parse Telegram WebApp init data.
|
||||
|
||||
Args:
|
||||
init_data: Raw init data string provided by Telegram WebApp.
|
||||
bot_token: Bot token used to verify the signature.
|
||||
max_age_seconds: Maximum allowed age for the payload. Defaults to 24 hours.
|
||||
|
||||
Returns:
|
||||
Parsed init data as a dictionary.
|
||||
|
||||
Raises:
|
||||
TelegramWebAppAuthError: If validation fails.
|
||||
"""
|
||||
|
||||
if not init_data:
|
||||
raise TelegramWebAppAuthError("Missing init data")
|
||||
|
||||
if not bot_token:
|
||||
raise TelegramWebAppAuthError("Bot token is not configured")
|
||||
|
||||
parsed_pairs = parse_qsl(init_data, strict_parsing=True, keep_blank_values=True)
|
||||
data: Dict[str, Any] = {key: value for key, value in parsed_pairs}
|
||||
|
||||
received_hash = data.pop("hash", None)
|
||||
if not received_hash:
|
||||
raise TelegramWebAppAuthError("Missing init data signature")
|
||||
|
||||
data_check_string = "\n".join(
|
||||
f"{key}={value}" for key, value in sorted(data.items())
|
||||
)
|
||||
|
||||
secret_key = hmac.new(
|
||||
key=b"WebAppData",
|
||||
msg=bot_token.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
computed_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(computed_hash, received_hash):
|
||||
raise TelegramWebAppAuthError("Invalid init data signature")
|
||||
|
||||
auth_date_raw = data.get("auth_date")
|
||||
if auth_date_raw is not None:
|
||||
try:
|
||||
auth_date = int(auth_date_raw)
|
||||
except (TypeError, ValueError):
|
||||
raise TelegramWebAppAuthError("Invalid auth_date value") from None
|
||||
|
||||
if max_age_seconds and auth_date:
|
||||
current_ts = int(time.time())
|
||||
if current_ts - auth_date > max_age_seconds:
|
||||
raise TelegramWebAppAuthError("Init data is too old")
|
||||
|
||||
data["auth_date"] = auth_date
|
||||
|
||||
user_payload = data.get("user")
|
||||
if user_payload is not None:
|
||||
try:
|
||||
data["user"] = json.loads(user_payload)
|
||||
except json.JSONDecodeError as error:
|
||||
raise TelegramWebAppAuthError("Invalid user payload") from error
|
||||
|
||||
return data
|
||||
|
||||
@@ -189,7 +189,7 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int
|
||||
and_(
|
||||
Transaction.user_id == referral.id,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True
|
||||
Transaction.is_completed.is_(True)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,8 +12,12 @@ from .routes import (
|
||||
campaigns,
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
promocodes,
|
||||
miniapp,
|
||||
promo_groups,
|
||||
promo_offers,
|
||||
pages,
|
||||
remnawave,
|
||||
stats,
|
||||
subscriptions,
|
||||
@@ -37,6 +41,10 @@ OPENAPI_TAGS = [
|
||||
"name": "settings",
|
||||
"description": "Получение и изменение конфигурации бота из административной панели.",
|
||||
},
|
||||
{
|
||||
"name": "main-menu",
|
||||
"description": "Управление кнопками главного меню Telegram-бота.",
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
"description": "Управление пользователями, балансом и статусами подписок.",
|
||||
@@ -57,6 +65,10 @@ OPENAPI_TAGS = [
|
||||
"name": "promo-groups",
|
||||
"description": "Создание и управление промо-группами и их участниками.",
|
||||
},
|
||||
{
|
||||
"name": "promo-offers",
|
||||
"description": "Управление промо-предложениями, шаблонами и журналом событий.",
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": "Управление токенами доступа к административному API.",
|
||||
@@ -68,6 +80,14 @@ OPENAPI_TAGS = [
|
||||
"данных между ботом и панелью."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "miniapp",
|
||||
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
||||
},
|
||||
{
|
||||
"name": "pages",
|
||||
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -104,11 +124,19 @@ def create_web_api_app() -> FastAPI:
|
||||
app.include_router(tickets.router, prefix="/tickets", tags=["support"])
|
||||
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
|
||||
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
|
||||
app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
|
||||
app.include_router(
|
||||
main_menu_buttons.router,
|
||||
prefix="/main-menu/buttons",
|
||||
tags=["main-menu"],
|
||||
)
|
||||
app.include_router(pages.router, prefix="/pages", tags=["pages"])
|
||||
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
|
||||
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
|
||||
app.include_router(backups.router, prefix="/backups", tags=["backups"])
|
||||
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
|
||||
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
||||
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
|
||||
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from . import (
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
miniapp,
|
||||
promo_offers,
|
||||
pages,
|
||||
promo_groups,
|
||||
remnawave,
|
||||
stats,
|
||||
@@ -14,6 +18,10 @@ from . import (
|
||||
__all__ = [
|
||||
"config",
|
||||
"health",
|
||||
"main_menu_buttons",
|
||||
"miniapp",
|
||||
"promo_offers",
|
||||
"pages",
|
||||
"promo_groups",
|
||||
"remnawave",
|
||||
"stats",
|
||||
|
||||
@@ -5,7 +5,10 @@ from typing import Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.config import (
|
||||
@@ -94,6 +97,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
|
||||
current=current,
|
||||
original=original,
|
||||
has_override=has_override,
|
||||
read_only=bot_configuration_service.is_read_only(definition.key),
|
||||
choices=choices,
|
||||
)
|
||||
|
||||
@@ -153,7 +157,10 @@ async def update_setting(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
value = _coerce_value(key, payload.value)
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError as error:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
return _serialize_definition(definition)
|
||||
@@ -170,6 +177,9 @@ async def reset_setting(
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
try:
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
except ReadOnlySettingError as error:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
return _serialize_definition(definition)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.main_menu_button import (
|
||||
count_main_menu_buttons,
|
||||
create_main_menu_button,
|
||||
delete_main_menu_button,
|
||||
get_main_menu_button_by_id,
|
||||
get_main_menu_buttons,
|
||||
update_main_menu_button,
|
||||
)
|
||||
from app.database.models import MainMenuButton
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.main_menu_buttons import (
|
||||
MainMenuButtonCreateRequest,
|
||||
MainMenuButtonListResponse,
|
||||
MainMenuButtonResponse,
|
||||
MainMenuButtonUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(button: MainMenuButton) -> MainMenuButtonResponse:
|
||||
return MainMenuButtonResponse(
|
||||
id=button.id,
|
||||
text=button.text,
|
||||
action_type=button.action_type_enum,
|
||||
action_value=button.action_value,
|
||||
visibility=button.visibility_enum,
|
||||
is_active=button.is_active,
|
||||
display_order=button.display_order,
|
||||
created_at=button.created_at,
|
||||
updated_at=button.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=MainMenuButtonListResponse)
|
||||
async def list_main_menu_buttons(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> MainMenuButtonListResponse:
|
||||
total = await count_main_menu_buttons(db)
|
||||
buttons = await get_main_menu_buttons(db, limit=limit, offset=offset)
|
||||
|
||||
return MainMenuButtonListResponse(
|
||||
items=[_serialize(button) for button in buttons],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=MainMenuButtonResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_main_menu_button_endpoint(
|
||||
payload: MainMenuButtonCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MainMenuButtonResponse:
|
||||
button = await create_main_menu_button(
|
||||
db,
|
||||
text=payload.text,
|
||||
action_type=payload.action_type,
|
||||
action_value=payload.action_value,
|
||||
visibility=payload.visibility,
|
||||
is_active=payload.is_active,
|
||||
display_order=payload.display_order,
|
||||
)
|
||||
|
||||
MainMenuButtonService.invalidate_cache()
|
||||
return _serialize(button)
|
||||
|
||||
|
||||
@router.patch("/{button_id}", response_model=MainMenuButtonResponse)
|
||||
async def update_main_menu_button_endpoint(
|
||||
button_id: int,
|
||||
payload: MainMenuButtonUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MainMenuButtonResponse:
|
||||
button = await get_main_menu_button_by_id(db, button_id)
|
||||
if not button:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found")
|
||||
|
||||
update_payload = payload.dict(exclude_unset=True)
|
||||
button = await update_main_menu_button(db, button, **update_payload)
|
||||
|
||||
MainMenuButtonService.invalidate_cache()
|
||||
return _serialize(button)
|
||||
|
||||
|
||||
@router.delete("/{button_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_main_menu_button_endpoint(
|
||||
button_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
button = await get_main_menu_button_by_id(db, button_id)
|
||||
if not button:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found")
|
||||
|
||||
await delete_main_menu_button(db, button)
|
||||
MainMenuButtonService.invalidate_cache()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import Subscription, Transaction, User
|
||||
from app.services.remnawave_service import (
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.subscription_utils import get_happ_cryptolink_redirect_link
|
||||
from app.utils.telegram_webapp import (
|
||||
TelegramWebAppAuthError,
|
||||
parse_webapp_init_data,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session
|
||||
from ..schemas.miniapp import (
|
||||
MiniAppConnectedServer,
|
||||
MiniAppDevice,
|
||||
MiniAppPromoGroup,
|
||||
MiniAppSubscriptionRequest,
|
||||
MiniAppSubscriptionResponse,
|
||||
MiniAppSubscriptionUser,
|
||||
MiniAppTransaction,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _format_gb(value: Optional[float]) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _format_gb_label(value: float) -> str:
|
||||
absolute = abs(value)
|
||||
if absolute >= 100:
|
||||
return f"{value:.0f} GB"
|
||||
if absolute >= 10:
|
||||
return f"{value:.1f} GB"
|
||||
return f"{value:.2f} GB"
|
||||
|
||||
|
||||
def _format_limit_label(limit: Optional[int]) -> str:
|
||||
if not limit:
|
||||
return "Unlimited"
|
||||
return f"{limit} GB"
|
||||
|
||||
|
||||
def _bytes_to_gb(bytes_value: Optional[int]) -> float:
|
||||
if not bytes_value:
|
||||
return 0.0
|
||||
return round(bytes_value / (1024 ** 3), 2)
|
||||
|
||||
|
||||
def _status_label(status: str) -> str:
|
||||
mapping = {
|
||||
"active": "Active",
|
||||
"trial": "Trial",
|
||||
"expired": "Expired",
|
||||
"disabled": "Disabled",
|
||||
}
|
||||
return mapping.get(status, status.title())
|
||||
|
||||
|
||||
def _parse_datetime_string(value: Optional[str]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
cleaned = value.strip()
|
||||
if cleaned.endswith("Z"):
|
||||
cleaned = f"{cleaned[:-1]}+00:00"
|
||||
# Normalize duplicated timezone suffixes like +00:00+00:00
|
||||
if "+00:00+00:00" in cleaned:
|
||||
cleaned = cleaned.replace("+00:00+00:00", "+00:00")
|
||||
|
||||
datetime.fromisoformat(cleaned)
|
||||
return cleaned
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return value
|
||||
|
||||
|
||||
async def _resolve_connected_servers(
|
||||
db: AsyncSession,
|
||||
squad_uuids: List[str],
|
||||
) -> List[MiniAppConnectedServer]:
|
||||
if not squad_uuids:
|
||||
return []
|
||||
|
||||
resolved: Dict[str, str] = {}
|
||||
missing: List[str] = []
|
||||
|
||||
for squad_uuid in squad_uuids:
|
||||
if squad_uuid in resolved:
|
||||
continue
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server and server.display_name:
|
||||
resolved[squad_uuid] = server.display_name
|
||||
else:
|
||||
missing.append(squad_uuid)
|
||||
|
||||
if missing:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
if service.is_configured:
|
||||
squads = await service.get_all_squads()
|
||||
for squad in squads:
|
||||
uuid = squad.get("uuid")
|
||||
name = squad.get("name")
|
||||
if uuid in missing and name:
|
||||
resolved[uuid] = name
|
||||
except RemnaWaveConfigurationError:
|
||||
logger.debug("RemnaWave is not configured; skipping server name enrichment")
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to resolve server names from RemnaWave: %s", error)
|
||||
|
||||
connected_servers: List[MiniAppConnectedServer] = []
|
||||
for squad_uuid in squad_uuids:
|
||||
name = resolved.get(squad_uuid, squad_uuid)
|
||||
connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name))
|
||||
|
||||
return connected_servers
|
||||
|
||||
|
||||
async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]:
|
||||
remnawave_uuid = getattr(user, "remnawave_uuid", None)
|
||||
if not remnawave_uuid:
|
||||
return 0, []
|
||||
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to initialise RemnaWave service: %s", error)
|
||||
return 0, []
|
||||
|
||||
if not service.is_configured:
|
||||
return 0, []
|
||||
|
||||
try:
|
||||
async with service.get_api_client() as api:
|
||||
response = await api.get_user_devices(remnawave_uuid)
|
||||
except RemnaWaveConfigurationError:
|
||||
logger.debug("RemnaWave configuration missing while loading devices")
|
||||
return 0, []
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to load devices from RemnaWave: %s", error)
|
||||
return 0, []
|
||||
|
||||
total_devices = int(response.get("total") or 0)
|
||||
devices_payload = response.get("devices") or []
|
||||
|
||||
devices: List[MiniAppDevice] = []
|
||||
for device in devices_payload:
|
||||
platform = device.get("platform") or device.get("platformType")
|
||||
model = device.get("deviceModel") or device.get("model") or device.get("name")
|
||||
app_version = device.get("appVersion") or device.get("version")
|
||||
last_seen_raw = (
|
||||
device.get("updatedAt")
|
||||
or device.get("lastSeen")
|
||||
or device.get("lastActiveAt")
|
||||
or device.get("createdAt")
|
||||
)
|
||||
last_ip = device.get("ip") or device.get("ipAddress")
|
||||
|
||||
devices.append(
|
||||
MiniAppDevice(
|
||||
platform=platform,
|
||||
device_model=model,
|
||||
app_version=app_version,
|
||||
last_seen=_parse_datetime_string(last_seen_raw),
|
||||
last_ip=last_ip,
|
||||
)
|
||||
)
|
||||
|
||||
if total_devices == 0:
|
||||
total_devices = len(devices)
|
||||
|
||||
return total_devices, devices
|
||||
|
||||
|
||||
def _resolve_display_name(user_data: Dict[str, Any]) -> str:
|
||||
username = user_data.get("username")
|
||||
if username:
|
||||
return username
|
||||
|
||||
first = user_data.get("first_name")
|
||||
last = user_data.get("last_name")
|
||||
parts = [part for part in [first, last] if part]
|
||||
if parts:
|
||||
return " ".join(parts)
|
||||
|
||||
telegram_id = user_data.get("telegram_id")
|
||||
return f"User {telegram_id}" if telegram_id else "User"
|
||||
|
||||
|
||||
def _is_remnawave_configured() -> bool:
|
||||
params = settings.get_remnawave_auth_params()
|
||||
return bool(params.get("base_url") and params.get("api_key"))
|
||||
|
||||
|
||||
def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction:
|
||||
return MiniAppTransaction(
|
||||
id=transaction.id,
|
||||
type=transaction.type,
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
amount_rubles=round(transaction.amount_kopeks / 100, 2),
|
||||
description=transaction.description,
|
||||
payment_method=transaction.payment_method,
|
||||
external_id=transaction.external_id,
|
||||
is_completed=transaction.is_completed,
|
||||
created_at=transaction.created_at,
|
||||
completed_at=transaction.completed_at,
|
||||
)
|
||||
|
||||
|
||||
async def _load_subscription_links(
|
||||
subscription: Subscription,
|
||||
) -> Dict[str, Any]:
|
||||
if not subscription.remnawave_short_uuid or not _is_remnawave_configured():
|
||||
return {}
|
||||
|
||||
try:
|
||||
service = SubscriptionService()
|
||||
info = await service.get_subscription_info(subscription.remnawave_short_uuid)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to load subscription info from RemnaWave: %s", error)
|
||||
return {}
|
||||
|
||||
if not info:
|
||||
return {}
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"links": list(info.links or []),
|
||||
"ss_conf_links": dict(info.ss_conf_links or {}),
|
||||
"subscription_url": info.subscription_url,
|
||||
"happ": info.happ,
|
||||
"happ_link": getattr(info, "happ_link", None),
|
||||
"happ_crypto_link": getattr(info, "happ_crypto_link", None),
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/subscription", response_model=MiniAppSubscriptionResponse)
|
||||
async def get_subscription_details(
|
||||
payload: MiniAppSubscriptionRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionResponse:
|
||||
try:
|
||||
webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN)
|
||||
except TelegramWebAppAuthError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
telegram_user = webapp_data.get("user")
|
||||
if not isinstance(telegram_user, dict) or "id" not in telegram_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Telegram user payload",
|
||||
)
|
||||
|
||||
try:
|
||||
telegram_id = int(telegram_user["id"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Telegram user identifier",
|
||||
) from None
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
|
||||
if not user or not user.subscription:
|
||||
detail: Union[str, Dict[str, str]] = "Subscription not found"
|
||||
if purchase_url:
|
||||
detail = {
|
||||
"message": "Subscription not found",
|
||||
"purchase_url": purchase_url,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
subscription = user.subscription
|
||||
traffic_used = _format_gb(subscription.traffic_used_gb)
|
||||
traffic_limit = subscription.traffic_limit_gb or 0
|
||||
lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0))
|
||||
|
||||
status_actual = subscription.actual_status
|
||||
links_payload = await _load_subscription_links(subscription)
|
||||
|
||||
subscription_url = links_payload.get("subscription_url") or subscription.subscription_url
|
||||
subscription_crypto_link = (
|
||||
links_payload.get("happ_crypto_link")
|
||||
or subscription.subscription_crypto_link
|
||||
)
|
||||
|
||||
happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link)
|
||||
|
||||
connected_squads: List[str] = list(subscription.connected_squads or [])
|
||||
connected_servers = await _resolve_connected_servers(db, connected_squads)
|
||||
devices_count, devices = await _load_devices_info(user)
|
||||
links: List[str] = links_payload.get("links") or connected_squads
|
||||
ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {}
|
||||
|
||||
transactions_query = (
|
||||
select(Transaction)
|
||||
.where(Transaction.user_id == user.id)
|
||||
.order_by(Transaction.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
transactions_result = await db.execute(transactions_query)
|
||||
transactions = list(transactions_result.scalars().all())
|
||||
|
||||
balance_currency = getattr(user, "balance_currency", None)
|
||||
if isinstance(balance_currency, str):
|
||||
balance_currency = balance_currency.upper()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
response_user = MiniAppSubscriptionUser(
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
display_name=_resolve_display_name(
|
||||
{
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"telegram_id": user.telegram_id,
|
||||
}
|
||||
),
|
||||
language=user.language,
|
||||
status=user.status,
|
||||
subscription_status=subscription.status,
|
||||
subscription_actual_status=status_actual,
|
||||
status_label=_status_label(status_actual),
|
||||
expires_at=subscription.end_date,
|
||||
device_limit=subscription.device_limit,
|
||||
traffic_used_gb=round(traffic_used, 2),
|
||||
traffic_used_label=_format_gb_label(traffic_used),
|
||||
traffic_limit_gb=traffic_limit,
|
||||
traffic_limit_label=_format_limit_label(traffic_limit),
|
||||
lifetime_used_traffic_gb=lifetime_used,
|
||||
has_active_subscription=status_actual in {"active", "trial"},
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionResponse(
|
||||
subscription_id=subscription.id,
|
||||
remnawave_short_uuid=subscription.remnawave_short_uuid,
|
||||
user=response_user,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
subscription_purchase_url=purchase_url or None,
|
||||
links=links,
|
||||
ss_conf_links=ss_conf_links,
|
||||
connected_squads=connected_squads,
|
||||
connected_servers=connected_servers,
|
||||
connected_devices_count=devices_count,
|
||||
connected_devices=devices,
|
||||
happ=links_payload.get("happ"),
|
||||
happ_link=links_payload.get("happ_link"),
|
||||
happ_crypto_link=links_payload.get("happ_crypto_link"),
|
||||
happ_cryptolink_redirect_link=happ_redirect_link,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_rubles=round(user.balance_rubles, 2),
|
||||
balance_currency=balance_currency,
|
||||
transactions=[_serialize_transaction(tx) for tx in transactions],
|
||||
promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name)
|
||||
if promo_group
|
||||
else None,
|
||||
subscription_type="trial" if subscription.is_trial else "paid",
|
||||
autopay_enabled=bool(subscription.autopay_enabled),
|
||||
branding=settings.get_miniapp_branding(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.faq import get_faq_page_by_id
|
||||
from app.database.crud.rules import (
|
||||
clear_all_rules,
|
||||
create_or_update_rules,
|
||||
get_all_rules_versions,
|
||||
get_rules_by_language,
|
||||
restore_rules_version,
|
||||
)
|
||||
from app.database.models import ServiceRule
|
||||
from app.services.faq_service import FaqService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.pages import (
|
||||
FaqPageCreateRequest,
|
||||
FaqPageListResponse,
|
||||
FaqPageResponse,
|
||||
FaqPageUpdateRequest,
|
||||
FaqReorderRequest,
|
||||
FaqStatusResponse,
|
||||
FaqStatusUpdateRequest,
|
||||
RichTextPageResponse,
|
||||
RichTextPageUpdateRequest,
|
||||
ServiceRulesHistoryResponse,
|
||||
ServiceRulesResponse,
|
||||
ServiceRulesUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_rich_page(
|
||||
*,
|
||||
requested_language: str,
|
||||
content: str,
|
||||
language: str,
|
||||
is_enabled: Optional[bool],
|
||||
created_at,
|
||||
updated_at,
|
||||
splitter,
|
||||
) -> RichTextPageResponse:
|
||||
pages = splitter(content or "")
|
||||
return RichTextPageResponse(
|
||||
requested_language=requested_language,
|
||||
language=language,
|
||||
is_enabled=is_enabled,
|
||||
content=content or "",
|
||||
content_pages=pages,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_faq_page(page) -> FaqPageResponse:
|
||||
return FaqPageResponse(
|
||||
id=page.id,
|
||||
language=page.language,
|
||||
title=page.title,
|
||||
content=page.content,
|
||||
content_pages=FaqService.split_content_into_pages(page.content),
|
||||
display_order=page.display_order,
|
||||
is_active=page.is_active,
|
||||
created_at=page.created_at,
|
||||
updated_at=page.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_rules(rule: ServiceRule) -> ServiceRulesResponse:
|
||||
return ServiceRulesResponse(
|
||||
id=rule.id,
|
||||
title=rule.title,
|
||||
content=rule.content,
|
||||
language=rule.language,
|
||||
is_active=rule.is_active,
|
||||
created_at=rule.created_at,
|
||||
updated_at=rule.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public-offer", response_model=RichTextPageResponse)
|
||||
async def get_public_offer(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True, description="Использовать запасной язык, если контента нет"),
|
||||
include_disabled: bool = Query(
|
||||
True,
|
||||
description="Возвращать контент даже если страница выключена",
|
||||
),
|
||||
) -> RichTextPageResponse:
|
||||
requested_lang = PublicOfferService.normalize_language(language)
|
||||
offer = await PublicOfferService.get_offer(db, requested_lang, fallback=fallback)
|
||||
|
||||
if not offer:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer not found")
|
||||
|
||||
if not include_disabled and not offer.is_enabled:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer disabled")
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=requested_lang,
|
||||
language=offer.language,
|
||||
is_enabled=offer.is_enabled,
|
||||
content=offer.content or "",
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
splitter=PublicOfferService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/public-offer", response_model=RichTextPageResponse)
|
||||
async def update_public_offer(
|
||||
payload: RichTextPageUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RichTextPageResponse:
|
||||
lang = PublicOfferService.normalize_language(payload.language)
|
||||
offer = await PublicOfferService.save_offer(db, lang, payload.content)
|
||||
|
||||
if payload.is_enabled is not None:
|
||||
offer = await PublicOfferService.set_enabled(db, lang, payload.is_enabled)
|
||||
|
||||
refreshed = await PublicOfferService.get_offer(db, lang, fallback=False)
|
||||
offer = refreshed or offer
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=lang,
|
||||
language=offer.language,
|
||||
is_enabled=offer.is_enabled,
|
||||
content=offer.content or "",
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
splitter=PublicOfferService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/privacy-policy", response_model=RichTextPageResponse)
|
||||
async def get_privacy_policy(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True),
|
||||
include_disabled: bool = Query(True),
|
||||
) -> RichTextPageResponse:
|
||||
requested_lang = PrivacyPolicyService.normalize_language(language)
|
||||
policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=fallback)
|
||||
|
||||
if not policy:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy not found")
|
||||
|
||||
if not include_disabled and not policy.is_enabled:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy disabled")
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=requested_lang,
|
||||
language=policy.language,
|
||||
is_enabled=policy.is_enabled,
|
||||
content=policy.content or "",
|
||||
created_at=policy.created_at,
|
||||
updated_at=policy.updated_at,
|
||||
splitter=PrivacyPolicyService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/privacy-policy", response_model=RichTextPageResponse)
|
||||
async def update_privacy_policy(
|
||||
payload: RichTextPageUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RichTextPageResponse:
|
||||
lang = PrivacyPolicyService.normalize_language(payload.language)
|
||||
policy = await PrivacyPolicyService.save_policy(db, lang, payload.content)
|
||||
|
||||
if payload.is_enabled is not None:
|
||||
policy = await PrivacyPolicyService.set_enabled(db, lang, payload.is_enabled)
|
||||
|
||||
refreshed = await PrivacyPolicyService.get_policy(db, lang, fallback=False)
|
||||
policy = refreshed or policy
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=lang,
|
||||
language=policy.language,
|
||||
is_enabled=policy.is_enabled,
|
||||
content=policy.content or "",
|
||||
created_at=policy.created_at,
|
||||
updated_at=policy.updated_at,
|
||||
splitter=PrivacyPolicyService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/faq", response_model=FaqPageListResponse)
|
||||
async def list_faq_pages(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
include_inactive: bool = Query(True),
|
||||
fallback: bool = Query(True),
|
||||
) -> FaqPageListResponse:
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
pages = await FaqService.get_pages(
|
||||
db,
|
||||
requested_lang,
|
||||
include_inactive=include_inactive,
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
resolved_language = requested_lang
|
||||
if pages:
|
||||
resolved_language = pages[0].language
|
||||
|
||||
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
|
||||
is_enabled = bool(setting.is_enabled) if setting else False
|
||||
if setting:
|
||||
resolved_language = setting.language
|
||||
|
||||
serialized = [_serialize_faq_page(page) for page in pages]
|
||||
return FaqPageListResponse(
|
||||
requested_language=requested_lang,
|
||||
language=resolved_language,
|
||||
is_enabled=is_enabled,
|
||||
total=len(serialized),
|
||||
items=serialized,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/faq", response_model=FaqPageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_faq_page(
|
||||
payload: FaqPageCreateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqPageResponse:
|
||||
lang = FaqService.normalize_language(payload.language)
|
||||
is_active = True if payload.is_active is None else payload.is_active
|
||||
|
||||
page = await FaqService.create_page(
|
||||
db,
|
||||
language=lang,
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
display_order=payload.display_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
return _serialize_faq_page(page)
|
||||
|
||||
|
||||
@router.get("/faq/{page_id}", response_model=FaqPageResponse)
|
||||
async def get_faq_page(
|
||||
page_id: int,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
include_inactive: bool = Query(True),
|
||||
) -> FaqPageResponse:
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
page = await FaqService.get_page(
|
||||
db,
|
||||
page_id,
|
||||
requested_lang,
|
||||
include_inactive=include_inactive,
|
||||
fallback=True,
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
|
||||
|
||||
return _serialize_faq_page(page)
|
||||
|
||||
|
||||
@router.put("/faq/{page_id}", response_model=FaqPageResponse)
|
||||
async def update_faq_page(
|
||||
page_id: int,
|
||||
payload: FaqPageUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqPageResponse:
|
||||
page = await get_faq_page_by_id(db, page_id)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
|
||||
|
||||
updated = await FaqService.update_page(
|
||||
db,
|
||||
page,
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
display_order=payload.display_order,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
|
||||
return _serialize_faq_page(updated)
|
||||
|
||||
|
||||
@router.delete("/faq/{page_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_faq_page(
|
||||
page_id: int,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
page = await get_faq_page_by_id(db, page_id)
|
||||
if not page:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
|
||||
|
||||
await FaqService.delete_page(db, page_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/faq/reorder", response_model=FaqPageListResponse)
|
||||
async def reorder_faq_pages(
|
||||
payload: FaqReorderRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqPageListResponse:
|
||||
lang = FaqService.normalize_language(payload.language)
|
||||
|
||||
ordered_payload = sorted(payload.items, key=lambda item: item.display_order)
|
||||
|
||||
existing_pages = await FaqService.get_pages(
|
||||
db,
|
||||
lang,
|
||||
include_inactive=True,
|
||||
fallback=False,
|
||||
)
|
||||
pages_by_id = {page.id: page for page in existing_pages}
|
||||
|
||||
pages: List[Any] = []
|
||||
for item in ordered_payload:
|
||||
page = pages_by_id.get(item.id)
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
f"FAQ page {item.id} not found for language {lang}",
|
||||
)
|
||||
pages.append(page)
|
||||
|
||||
ordered_ids = {item.id for item in ordered_payload}
|
||||
remaining = [page for page in existing_pages if page.id not in ordered_ids]
|
||||
pages.extend(sorted(remaining, key=lambda page: (page.display_order, page.id)))
|
||||
|
||||
await FaqService.reorder_pages(db, lang, pages)
|
||||
|
||||
updated_pages = await FaqService.get_pages(
|
||||
db,
|
||||
lang,
|
||||
include_inactive=True,
|
||||
fallback=False,
|
||||
)
|
||||
setting = await FaqService.get_setting(db, lang, fallback=False)
|
||||
serialized = [_serialize_faq_page(page) for page in updated_pages]
|
||||
return FaqPageListResponse(
|
||||
requested_language=lang,
|
||||
language=lang,
|
||||
is_enabled=bool(setting.is_enabled) if setting else False,
|
||||
total=len(serialized),
|
||||
items=serialized,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/faq/status", response_model=FaqStatusResponse)
|
||||
async def get_faq_status(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True),
|
||||
) -> FaqStatusResponse:
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
|
||||
|
||||
if not setting:
|
||||
return FaqStatusResponse(
|
||||
requested_language=requested_lang,
|
||||
language=requested_lang,
|
||||
is_enabled=False,
|
||||
)
|
||||
|
||||
return FaqStatusResponse(
|
||||
requested_language=requested_lang,
|
||||
language=setting.language,
|
||||
is_enabled=bool(setting.is_enabled),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/faq/status", response_model=FaqStatusResponse)
|
||||
async def update_faq_status(
|
||||
payload: FaqStatusUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqStatusResponse:
|
||||
lang = FaqService.normalize_language(payload.language)
|
||||
setting = await FaqService.set_enabled(db, lang, payload.is_enabled)
|
||||
|
||||
return FaqStatusResponse(
|
||||
requested_language=lang,
|
||||
language=setting.language,
|
||||
is_enabled=bool(setting.is_enabled),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/service-rules", response_model=ServiceRulesResponse)
|
||||
async def get_service_rules(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True),
|
||||
) -> ServiceRulesResponse:
|
||||
requested_lang = language.split("-")[0].lower()
|
||||
rules = await get_rules_by_language(db, requested_lang)
|
||||
|
||||
if not rules and fallback:
|
||||
default_lang = (settings.DEFAULT_LANGUAGE or "ru").split("-")[0].lower()
|
||||
if default_lang != requested_lang:
|
||||
rules = await get_rules_by_language(db, default_lang)
|
||||
|
||||
if not rules:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Service rules not found")
|
||||
|
||||
return _serialize_rules(rules)
|
||||
|
||||
|
||||
@router.put("/service-rules", response_model=ServiceRulesResponse)
|
||||
async def update_service_rules(
|
||||
payload: ServiceRulesUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ServiceRulesResponse:
|
||||
lang = payload.language.split("-")[0].lower()
|
||||
title = payload.title or "Правила сервиса"
|
||||
rules = await create_or_update_rules(
|
||||
db,
|
||||
content=payload.content,
|
||||
language=lang,
|
||||
title=title,
|
||||
)
|
||||
|
||||
return _serialize_rules(rules)
|
||||
|
||||
|
||||
@router.delete("/service-rules", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def clear_service_rules(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
) -> Response:
|
||||
lang = language.split("-")[0].lower()
|
||||
await clear_all_rules(db, lang)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/service-rules/history", response_model=ServiceRulesHistoryResponse)
|
||||
async def get_service_rules_history(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
) -> ServiceRulesHistoryResponse:
|
||||
lang = language.split("-")[0].lower()
|
||||
history = await get_all_rules_versions(db, lang, limit=limit)
|
||||
items = [_serialize_rules(item) for item in history]
|
||||
return ServiceRulesHistoryResponse(
|
||||
language=lang,
|
||||
total=len(items),
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/service-rules/history/{rule_id}/restore",
|
||||
response_model=ServiceRulesResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def restore_service_rules_version(
|
||||
rule_id: int,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
) -> ServiceRulesResponse:
|
||||
lang = language.split("-")[0].lower()
|
||||
restored = await restore_rules_version(db, rule_id, language=lang)
|
||||
if not restored:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Rules version not found")
|
||||
return _serialize_rules(restored)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.discount_offer import (
|
||||
count_discount_offers,
|
||||
get_offer_by_id,
|
||||
list_discount_offers,
|
||||
upsert_discount_offer,
|
||||
)
|
||||
from app.database.crud.promo_offer_log import list_promo_offer_logs
|
||||
from app.database.crud.promo_offer_template import (
|
||||
get_promo_offer_template_by_id,
|
||||
list_promo_offer_templates,
|
||||
update_promo_offer_template,
|
||||
)
|
||||
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, Subscription, User
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.promo_offers import (
|
||||
PromoOfferCreateRequest,
|
||||
PromoOfferListResponse,
|
||||
PromoOfferLogListResponse,
|
||||
PromoOfferLogOfferInfo,
|
||||
PromoOfferLogResponse,
|
||||
PromoOfferResponse,
|
||||
PromoOfferSubscriptionInfo,
|
||||
PromoOfferTemplateListResponse,
|
||||
PromoOfferTemplateResponse,
|
||||
PromoOfferTemplateUpdateRequest,
|
||||
PromoOfferUserInfo,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]:
|
||||
if not user:
|
||||
return None
|
||||
|
||||
return PromoOfferUserInfo(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=getattr(user, "full_name", None),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[PromoOfferSubscriptionInfo]:
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
return PromoOfferSubscriptionInfo(
|
||||
id=subscription.id,
|
||||
status=subscription.status,
|
||||
is_trial=subscription.is_trial,
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse:
|
||||
return PromoOfferResponse(
|
||||
id=offer.id,
|
||||
user_id=offer.user_id,
|
||||
subscription_id=offer.subscription_id,
|
||||
notification_type=offer.notification_type,
|
||||
discount_percent=offer.discount_percent,
|
||||
bonus_amount_kopeks=offer.bonus_amount_kopeks,
|
||||
expires_at=offer.expires_at,
|
||||
claimed_at=offer.claimed_at,
|
||||
is_active=offer.is_active,
|
||||
effect_type=offer.effect_type,
|
||||
extra_data=offer.extra_data or {},
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
user=_serialize_user(getattr(offer, "user", None)),
|
||||
subscription=_serialize_subscription(getattr(offer, "subscription", None)),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateResponse:
|
||||
return PromoOfferTemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
offer_type=template.offer_type,
|
||||
message_text=template.message_text,
|
||||
button_text=template.button_text,
|
||||
valid_hours=template.valid_hours,
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=template.bonus_amount_kopeks,
|
||||
active_discount_hours=template.active_discount_hours,
|
||||
test_duration_hours=template.test_duration_hours,
|
||||
test_squad_uuids=[str(uuid) for uuid in (template.test_squad_uuids or [])],
|
||||
is_active=template.is_active,
|
||||
created_by=template.created_by,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_log_response(entry: PromoOfferLog) -> PromoOfferLogResponse:
|
||||
user_info = _serialize_user(getattr(entry, "user", None))
|
||||
|
||||
offer = getattr(entry, "offer", None)
|
||||
offer_info: Optional[PromoOfferLogOfferInfo] = None
|
||||
if offer:
|
||||
offer_info = PromoOfferLogOfferInfo(
|
||||
id=offer.id,
|
||||
notification_type=offer.notification_type,
|
||||
discount_percent=offer.discount_percent,
|
||||
bonus_amount_kopeks=offer.bonus_amount_kopeks,
|
||||
effect_type=offer.effect_type,
|
||||
expires_at=offer.expires_at,
|
||||
claimed_at=offer.claimed_at,
|
||||
is_active=offer.is_active,
|
||||
)
|
||||
|
||||
return PromoOfferLogResponse(
|
||||
id=entry.id,
|
||||
user_id=entry.user_id,
|
||||
offer_id=entry.offer_id,
|
||||
action=entry.action,
|
||||
source=entry.source,
|
||||
percent=entry.percent,
|
||||
effect_type=entry.effect_type,
|
||||
details=entry.details or {},
|
||||
created_at=entry.created_at,
|
||||
user=user_info,
|
||||
offer=offer_info,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoOfferListResponse)
|
||||
async def list_promo_offers(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: Optional[int] = Query(None, ge=1),
|
||||
notification_type: Optional[str] = Query(None, min_length=1),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
) -> PromoOfferListResponse:
|
||||
offers = await list_discount_offers(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
notification_type=notification_type,
|
||||
is_active=is_active,
|
||||
)
|
||||
total = await count_discount_offers(
|
||||
db,
|
||||
user_id=user_id,
|
||||
notification_type=notification_type,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
return PromoOfferListResponse(
|
||||
items=[_serialize_offer(offer) for offer in offers],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoOfferResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promo_offer(
|
||||
payload: PromoOfferCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferResponse:
|
||||
if payload.discount_percent < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
|
||||
if payload.bonus_amount_kopeks < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
|
||||
if payload.valid_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
|
||||
if not payload.notification_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "notification_type must not be empty")
|
||||
if not payload.effect_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty")
|
||||
|
||||
user = await db.get(User, payload.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
if payload.subscription_id is not None:
|
||||
subscription = await db.get(Subscription, payload.subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
if subscription.user_id != payload.user_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription does not belong to the user")
|
||||
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
subscription_id=payload.subscription_id,
|
||||
notification_type=payload.notification_type.strip(),
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
valid_hours=payload.valid_hours,
|
||||
effect_type=payload.effect_type,
|
||||
extra_data=payload.extra_data,
|
||||
)
|
||||
|
||||
await db.refresh(offer, attribute_names=["user", "subscription"])
|
||||
|
||||
return _serialize_offer(offer)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=PromoOfferLogListResponse)
|
||||
async def get_promo_offer_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: Optional[int] = Query(None, ge=1),
|
||||
offer_id: Optional[int] = Query(None, ge=1),
|
||||
action: Optional[str] = Query(None, min_length=1),
|
||||
source: Optional[str] = Query(None, min_length=1),
|
||||
) -> PromoOfferLogListResponse:
|
||||
logs, total = await list_promo_offer_logs(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
offer_id=offer_id,
|
||||
action=action,
|
||||
source=source,
|
||||
)
|
||||
|
||||
return PromoOfferLogListResponse(
|
||||
items=[_build_log_response(entry) for entry in logs],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=PromoOfferTemplateListResponse)
|
||||
async def list_promo_offer_templates_endpoint(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferTemplateListResponse:
|
||||
templates = await list_promo_offer_templates(db)
|
||||
return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates])
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
|
||||
async def get_promo_offer_template_endpoint(
|
||||
template_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found")
|
||||
return _serialize_template(template)
|
||||
|
||||
|
||||
@router.patch("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
|
||||
async def update_promo_offer_template_endpoint(
|
||||
template_id: int,
|
||||
payload: PromoOfferTemplateUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found")
|
||||
|
||||
if payload.valid_hours is not None and payload.valid_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
|
||||
if payload.active_discount_hours is not None and payload.active_discount_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "active_discount_hours must be positive")
|
||||
if payload.test_duration_hours is not None and payload.test_duration_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "test_duration_hours must be positive")
|
||||
if payload.discount_percent is not None and payload.discount_percent < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
|
||||
if payload.bonus_amount_kopeks is not None and payload.bonus_amount_kopeks < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
|
||||
|
||||
if payload.test_squad_uuids is not None:
|
||||
normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()]
|
||||
else:
|
||||
normalized_squads = None
|
||||
|
||||
updated_template = await update_promo_offer_template(
|
||||
db,
|
||||
template,
|
||||
name=payload.name,
|
||||
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=normalized_squads,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
|
||||
return _serialize_template(updated_template)
|
||||
|
||||
|
||||
@router.get("/{offer_id}", response_model=PromoOfferResponse)
|
||||
async def get_promo_offer_endpoint(
|
||||
offer_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferResponse:
|
||||
offer = await get_offer_by_id(db, offer_id)
|
||||
if not offer:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer not found")
|
||||
|
||||
return _serialize_offer(offer)
|
||||
@@ -131,6 +131,7 @@ async def create_subscription(
|
||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
connected_squads=payload.connected_squads or [],
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
|
||||
@@ -45,6 +45,7 @@ class SettingDefinition(BaseModel):
|
||||
current: Any | None = Field(default=None)
|
||||
original: Any | None = Field(default=None)
|
||||
has_override: bool
|
||||
read_only: bool = Field(default=False)
|
||||
choices: list[SettingChoice] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from app.database.models import MainMenuButtonActionType, MainMenuButtonVisibility
|
||||
|
||||
|
||||
def _clean_text(value: str) -> str:
|
||||
cleaned = (value or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Text cannot be empty")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _validate_action_value(value: str) -> str:
|
||||
cleaned = (value or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Action value cannot be empty")
|
||||
if not cleaned.lower().startswith(("http://", "https://")):
|
||||
raise ValueError("Action value must start with http:// or https://")
|
||||
return cleaned
|
||||
|
||||
|
||||
class MainMenuButtonResponse(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
action_type: MainMenuButtonActionType
|
||||
action_value: str
|
||||
visibility: MainMenuButtonVisibility
|
||||
is_active: bool
|
||||
display_order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class MainMenuButtonCreateRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=64)
|
||||
action_type: MainMenuButtonActionType
|
||||
action_value: str = Field(..., min_length=1, max_length=1024)
|
||||
visibility: MainMenuButtonVisibility = MainMenuButtonVisibility.ALL
|
||||
is_active: bool = True
|
||||
display_order: Optional[int] = Field(None, ge=0)
|
||||
|
||||
_normalize_text = validator("text", allow_reuse=True)(_clean_text)
|
||||
_normalize_action_value = validator("action_value", allow_reuse=True)(_validate_action_value)
|
||||
|
||||
|
||||
class MainMenuButtonUpdateRequest(BaseModel):
|
||||
text: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
action_type: Optional[MainMenuButtonActionType] = None
|
||||
action_value: Optional[str] = Field(None, min_length=1, max_length=1024)
|
||||
visibility: Optional[MainMenuButtonVisibility] = None
|
||||
is_active: Optional[bool] = None
|
||||
display_order: Optional[int] = Field(None, ge=0)
|
||||
|
||||
@validator("text")
|
||||
def validate_text(cls, value): # noqa: D401,B902
|
||||
if value is None:
|
||||
return value
|
||||
return _clean_text(value)
|
||||
|
||||
@validator("action_value")
|
||||
def validate_action_value(cls, value): # noqa: D401,B902
|
||||
if value is None:
|
||||
return value
|
||||
return _validate_action_value(value)
|
||||
|
||||
|
||||
class MainMenuButtonListResponse(BaseModel):
|
||||
items: list[MainMenuButtonResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MiniAppBranding(BaseModel):
|
||||
service_name: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
service_description: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MiniAppSubscriptionRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
|
||||
class MiniAppSubscriptionUser(BaseModel):
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
display_name: str
|
||||
language: Optional[str] = None
|
||||
status: str
|
||||
subscription_status: str
|
||||
subscription_actual_status: str
|
||||
status_label: str
|
||||
expires_at: Optional[datetime] = None
|
||||
device_limit: Optional[int] = None
|
||||
traffic_used_gb: float = 0.0
|
||||
traffic_used_label: str
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
traffic_limit_label: str
|
||||
lifetime_used_traffic_gb: float = 0.0
|
||||
has_active_subscription: bool = False
|
||||
|
||||
|
||||
class MiniAppPromoGroup(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class MiniAppConnectedServer(BaseModel):
|
||||
uuid: str
|
||||
name: str
|
||||
|
||||
|
||||
class MiniAppDevice(BaseModel):
|
||||
platform: Optional[str] = None
|
||||
device_model: Optional[str] = None
|
||||
app_version: Optional[str] = None
|
||||
last_seen: Optional[str] = None
|
||||
last_ip: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppTransaction(BaseModel):
|
||||
id: int
|
||||
type: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
description: Optional[str] = None
|
||||
payment_method: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
is_completed: bool
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionResponse(BaseModel):
|
||||
success: bool = True
|
||||
subscription_id: int
|
||||
remnawave_short_uuid: Optional[str] = None
|
||||
user: MiniAppSubscriptionUser
|
||||
subscription_url: Optional[str] = None
|
||||
subscription_crypto_link: Optional[str] = None
|
||||
subscription_purchase_url: Optional[str] = None
|
||||
links: List[str] = Field(default_factory=list)
|
||||
ss_conf_links: Dict[str, str] = Field(default_factory=dict)
|
||||
connected_squads: List[str] = Field(default_factory=list)
|
||||
connected_servers: List[MiniAppConnectedServer] = Field(default_factory=list)
|
||||
connected_devices_count: int = 0
|
||||
connected_devices: List[MiniAppDevice] = Field(default_factory=list)
|
||||
happ: Optional[Dict[str, Any]] = None
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
happ_cryptolink_redirect_link: Optional[str] = None
|
||||
balance_kopeks: int = 0
|
||||
balance_rubles: float = 0.0
|
||||
balance_currency: Optional[str] = None
|
||||
transactions: List[MiniAppTransaction] = Field(default_factory=list)
|
||||
promo_group: Optional[MiniAppPromoGroup] = None
|
||||
subscription_type: str
|
||||
autopay_enabled: bool = False
|
||||
branding: Optional[MiniAppBranding] = None
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RichTextPageResponse(BaseModel):
|
||||
"""Generic representation for rich text informational pages."""
|
||||
|
||||
requested_language: str = Field(..., description="Язык, запрошенный клиентом")
|
||||
language: str = Field(..., description="Фактический язык найденной записи")
|
||||
is_enabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Текущий статус публикации страницы (если применимо)",
|
||||
)
|
||||
content: str = Field(..., description="Полное содержимое страницы")
|
||||
content_pages: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Содержимое, разбитое на страницы фиксированной длины",
|
||||
)
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Дата создания записи",
|
||||
)
|
||||
updated_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Дата последнего обновления записи",
|
||||
)
|
||||
|
||||
|
||||
class RichTextPageUpdateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык, для которого выполняется обновление",
|
||||
)
|
||||
content: str = Field(..., description="Новое содержимое страницы")
|
||||
is_enabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Если указано — обновить статус публикации",
|
||||
)
|
||||
|
||||
|
||||
class FaqPageResponse(BaseModel):
|
||||
id: int
|
||||
language: str
|
||||
title: str
|
||||
content: str
|
||||
content_pages: List[str] = Field(default_factory=list)
|
||||
display_order: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FaqPageListResponse(BaseModel):
|
||||
requested_language: str
|
||||
language: str
|
||||
is_enabled: bool
|
||||
total: int
|
||||
items: List[FaqPageResponse]
|
||||
|
||||
|
||||
class FaqPageCreateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык создаваемой страницы",
|
||||
)
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = Field(...)
|
||||
display_order: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description="Порядок отображения (если не указан — будет рассчитан автоматически)",
|
||||
)
|
||||
is_active: Optional[bool] = Field(
|
||||
default=True,
|
||||
description="Начальный статус активности страницы",
|
||||
)
|
||||
|
||||
|
||||
class FaqPageUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
content: Optional[str] = None
|
||||
display_order: Optional[int] = Field(default=None, ge=0)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class FaqReorderItem(BaseModel):
|
||||
id: int = Field(..., ge=1)
|
||||
display_order: int = Field(..., ge=0)
|
||||
|
||||
|
||||
class FaqReorderRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык, для которого применяется сортировка",
|
||||
)
|
||||
items: List[FaqReorderItem]
|
||||
|
||||
|
||||
class FaqStatusResponse(BaseModel):
|
||||
requested_language: str
|
||||
language: str
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
class FaqStatusUpdateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
)
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
class ServiceRulesResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
language: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ServiceRulesUpdateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык, для которого обновляются правила",
|
||||
)
|
||||
title: Optional[str] = Field(
|
||||
default="Правила сервиса",
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
)
|
||||
content: str = Field(...)
|
||||
|
||||
|
||||
class ServiceRulesHistoryResponse(BaseModel):
|
||||
language: str
|
||||
total: int
|
||||
items: List[ServiceRulesResponse]
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromoOfferUserInfo(BaseModel):
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class PromoOfferSubscriptionInfo(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
is_trial: bool
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
autopay_enabled: bool
|
||||
|
||||
|
||||
class PromoOfferResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
subscription_id: Optional[int] = None
|
||||
notification_type: str
|
||||
discount_percent: int
|
||||
bonus_amount_kopeks: int
|
||||
expires_at: datetime
|
||||
claimed_at: Optional[datetime] = None
|
||||
is_active: bool
|
||||
effect_type: str
|
||||
extra_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
user: Optional[PromoOfferUserInfo] = None
|
||||
subscription: Optional[PromoOfferSubscriptionInfo] = None
|
||||
|
||||
|
||||
class PromoOfferListResponse(BaseModel):
|
||||
items: List[PromoOfferResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PromoOfferCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
notification_type: str = Field(..., min_length=1)
|
||||
valid_hours: int = Field(..., ge=1, description="Срок действия предложения в часах")
|
||||
discount_percent: int = Field(0, ge=0)
|
||||
bonus_amount_kopeks: int = Field(0, ge=0)
|
||||
subscription_id: Optional[int] = None
|
||||
effect_type: str = Field("percent_discount", min_length=1)
|
||||
extra_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PromoOfferTemplateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
offer_type: str
|
||||
message_text: str
|
||||
button_text: str
|
||||
valid_hours: int
|
||||
discount_percent: int
|
||||
bonus_amount_kopeks: int
|
||||
active_discount_hours: Optional[int] = None
|
||||
test_duration_hours: Optional[int] = None
|
||||
test_squad_uuids: List[str]
|
||||
is_active: bool
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PromoOfferTemplateListResponse(BaseModel):
|
||||
items: List[PromoOfferTemplateResponse]
|
||||
|
||||
|
||||
class PromoOfferTemplateUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
message_text: Optional[str] = None
|
||||
button_text: Optional[str] = None
|
||||
valid_hours: Optional[int] = Field(None, ge=1)
|
||||
discount_percent: Optional[int] = Field(None, ge=0)
|
||||
bonus_amount_kopeks: Optional[int] = Field(None, ge=0)
|
||||
active_discount_hours: Optional[int] = Field(None, ge=1)
|
||||
test_duration_hours: Optional[int] = Field(None, ge=1)
|
||||
test_squad_uuids: Optional[List[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class PromoOfferLogOfferInfo(BaseModel):
|
||||
id: int
|
||||
notification_type: Optional[str] = None
|
||||
discount_percent: Optional[int] = None
|
||||
bonus_amount_kopeks: Optional[int] = None
|
||||
effect_type: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
claimed_at: Optional[datetime] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class PromoOfferLogResponse(BaseModel):
|
||||
id: int
|
||||
user_id: Optional[int] = None
|
||||
offer_id: Optional[int] = None
|
||||
action: str
|
||||
source: Optional[str] = None
|
||||
percent: Optional[int] = None
|
||||
effect_type: Optional[str] = None
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
user: Optional[PromoOfferUserInfo] = None
|
||||
offer: Optional[PromoOfferLogOfferInfo] = None
|
||||
|
||||
|
||||
class PromoOfferLogListResponse(BaseModel):
|
||||
items: List[PromoOfferLogResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -0,0 +1,116 @@
|
||||
# Настройка мини-приложения Telegram подписки
|
||||
|
||||
Эта инструкция описывает, как запустить статическую страницу из каталога `miniapp/index.html`, подключить её к административному API бота и опубликовать через reverse-proxy (nginx или Caddy). Страница отображает текущую подписку пользователя и использует Telegram WebApp init data для авторизации.
|
||||
|
||||
## 1. Требования
|
||||
|
||||
- Развёрнутый бот Bedolaga c актуальной базой данных.
|
||||
- Включённое административное API (`WEB_API_ENABLED=true`).
|
||||
- Доменное имя с действующим TLS-сертификатом (Telegram открывает веб-приложения только по HTTPS).
|
||||
- Возможность разместить статические файлы (`miniapp/index.html` и `miniapp/app-config.json`) и проксировать запросы `/miniapp/*` к боту.
|
||||
|
||||
## 2. Настройка окружения
|
||||
|
||||
1. Скопируйте пример конфигурации и включите веб-API:
|
||||
2. Задайте как минимум следующие переменные:
|
||||
```env
|
||||
WEB_API_ENABLED=true # включает FastAPI
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8080
|
||||
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
|
||||
WEB_API_DEFAULT_TOKEN=super-secret-token
|
||||
```
|
||||
- `WEB_API_ALLOWED_ORIGINS` должен содержать домен, с которого будет открываться мини-приложение.
|
||||
- `WEB_API_DEFAULT_TOKEN` создаёт bootstrap-токен для запросов от страницы. Его можно заменить на токен, созданный через `POST /tokens`.
|
||||
|
||||
## 3. Запуск административного API
|
||||
|
||||
После старта проверьте доступность:
|
||||
```bash
|
||||
curl -H "X-API-Key: super-secret-token" https://miniapp.example.com/miniapp/health || \
|
||||
curl -H "X-API-Key: super-secret-token" http://127.0.0.1:8080/health
|
||||
```
|
||||
|
||||
## 4. Подготовка статических файлов
|
||||
|
||||
1. При необходимости отредактируйте `miniapp/app-config.json`, чтобы настроить инструкции и ссылки на нужные клиенты.
|
||||
2. Убедитесь, что файлы доступны для чтения пользователем веб-сервера.
|
||||
|
||||
## 5. Настройка кнопки в Telegram
|
||||
|
||||
1. В дминке бота настраиваем параметры - Конфигурации бота - Прочее - Miniapp
|
||||
2. Перезапустите бота.
|
||||
3. При необходимости задайте кастомную кнопку на миниапп через `@BotFather` (команда `/setmenu` -> Web App URL).
|
||||
|
||||
## 6. Конфигурация nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name miniapp.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/miniapp.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/miniapp.example.com/privkey.pem;
|
||||
|
||||
# Статические файлы мини-приложения
|
||||
root /var/www/remnawave-miniapp;
|
||||
index index.html;
|
||||
|
||||
location = /miniapp/app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
# Проксирование запросов к административному API
|
||||
location /miniapp/ {
|
||||
proxy_pass http://127.0.0.1:8080/miniapp/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Если нужно проксировать другие эндпоинты API, добавьте аналогичные location-блоки.
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Конфигурация Caddy
|
||||
|
||||
```caddy
|
||||
miniapp.example.com {
|
||||
encode gzip zstd
|
||||
root * /var/www/remnawave-miniapp
|
||||
file_server
|
||||
|
||||
@config path /app-config.json
|
||||
header @config Access-Control-Allow-Origin "*"
|
||||
|
||||
reverse_proxy /miniapp/* 127.0.0.1:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
```
|
||||
Caddy автоматически выпустит сертификаты через ACME. Убедитесь, что порт 443 проброшен и домен указывает на сервер.
|
||||
|
||||
## 8. Проверка работы
|
||||
|
||||
1. Откройте мини-приложение прямо в Telegram или через браузер: `https://miniapp.example.com`.
|
||||
2. В консоли разработчика убедитесь, что запрос к `https://miniapp.example.com/miniapp/subscription` возвращает JSON с данными подписки.
|
||||
3. Проверьте, что ссылки из блока «Подключить подписку» открываются и копируются без ошибок.
|
||||
|
||||
## 9. Диагностика
|
||||
|
||||
| Симптом | Возможная причина | Проверка |
|
||||
|---------|------------------|----------|
|
||||
| Белый экран и ошибка 401 | Неверный `X-API-Key` или `WEB_API_ALLOWED_ORIGINS`. | Проверьте токен и заголовки запроса, перегенерируйте токен через `/tokens`. |
|
||||
| Ошибка 404 на `/miniapp/subscription` | Прокси не пробрасывает запросы или API не запущено. | Проверьте лог nginx/Caddy и убедитесь, что бот запущен с `WEB_API_ENABLED=true`. |
|
||||
| Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. |
|
||||
| Нет ссылок подписки | Не настроена интеграция с RemnaWave или у пользователя нет активной подписки. | Проверьте `REMNAWAVE_API_URL/KEY` и статус подписки пользователя. |
|
||||
|
||||
После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую.
|
||||
@@ -127,8 +127,18 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
| `POST` | `/promo-groups` | Создать промо-группу.
|
||||
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
|
||||
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
|
||||
| `GET` | `/promo-offers` | Список промо-предложений с фильтрами по пользователю, статусу и типу уведомления.
|
||||
| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю.
|
||||
| `GET` | `/promo-offers/{id}` | Детали конкретного промо-предложения.
|
||||
| `GET` | `/promo-offers/templates` | Список шаблонов промо-предложений.
|
||||
| `GET` | `/promo-offers/templates/{id}` | Получить данные шаблона промо-предложения.
|
||||
| `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона.
|
||||
| `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения).
|
||||
| `GET` | `/tokens` | Управление токенами доступа.
|
||||
|
||||
> Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку
|
||||
> текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций).
|
||||
|
||||
### RemnaWave интеграция
|
||||
|
||||
После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота:
|
||||
|
||||
Executable
+1454
File diff suppressed because it is too large
Load Diff
+640
-7
@@ -15,6 +15,7 @@
|
||||
"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",
|
||||
@@ -54,6 +55,8 @@
|
||||
"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",
|
||||
"MENU_BALANCE": "💰 Balance",
|
||||
@@ -85,7 +88,13 @@
|
||||
"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.",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
|
||||
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
||||
"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_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
||||
"PAL24_CARD_PAY_BUTTON": "💳 Pay with a bank card (PayPalych)",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n{steps}\n\n❓ Need help? Contact {support}",
|
||||
"PAL24_INSTRUCTION_BUTTON": "{step}. Press “{button}”",
|
||||
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
|
||||
"PAL24_INSTRUCTION_CONFIRM": "{step}. Confirm the transfer",
|
||||
"PAL24_INSTRUCTION_COMPLETE": "{step}. The funds will be credited automatically",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
|
||||
@@ -207,7 +216,6 @@
|
||||
"NO_TICKETS_ADMIN": "No tickets to display.",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Enter support reply:",
|
||||
|
||||
"ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!",
|
||||
"TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.",
|
||||
"TICKET_UPDATE_ERROR": "❌ Error updating ticket.",
|
||||
@@ -322,6 +330,34 @@
|
||||
"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_FAQ": "❓ FAQ",
|
||||
"FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"FAQ_PAGES_PROMPT": "Select a topic:",
|
||||
"FAQ_NOT_AVAILABLE": "FAQ is temporarily unavailable.",
|
||||
"FAQ_PAGE_UNTITLED": "Untitled",
|
||||
"FAQ_PAGE_NOT_AVAILABLE": "This FAQ page is not available.",
|
||||
"FAQ_PAGE_EMPTY": "Content for this page has not been added yet.",
|
||||
"FAQ_PAGE_TITLE": "<b>{title}</b>",
|
||||
"FAQ_PAGE_FOOTER": "Page {current} of {total}",
|
||||
"FAQ_BACK_TO_LIST": "⬅️ Back to FAQ list",
|
||||
"MENU_PRIVACY_POLICY": "🛡️ Privacy policy",
|
||||
"PRIVACY_POLICY_HEADER": "🛡️ <b>Privacy policy</b>",
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE": "The privacy policy is temporarily unavailable.",
|
||||
"PRIVACY_POLICY_EMPTY_ALERT": "The privacy policy text has not been provided yet.",
|
||||
"PRIVACY_POLICY_PAGE_INFO": "Page {current} of {total}",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
@@ -521,19 +557,616 @@
|
||||
"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!",
|
||||
"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\nTap “Get discount” and we'll add {bonus} to your balance. The offer 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 — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
|
||||
"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}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It stacks with other discounts and will apply automatically to your next payment.",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
|
||||
"PROMO_OFFER_CLOSE": "❌ Close",
|
||||
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ You need an active subscription to use this offer.",
|
||||
"TEST_ACCESS_NO_SQUADS": "❌ Unable to determine servers for the test access. Please contact support.",
|
||||
"TEST_ACCESS_REMNAWAVE_ERROR": "❌ Failed to connect the servers. Please try again later or contact support.",
|
||||
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Failed to activate the offer. Please try again later.",
|
||||
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ This server is already connected to your subscription.",
|
||||
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Test servers are connected! Access is active until {expires_at}.",
|
||||
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Access granted!",
|
||||
"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>",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):"
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
|
||||
"ADMIN_MAIN_SERVERS": "🌐 Servers",
|
||||
"ADMIN_MAIN_PRICING": "💰 Pricing",
|
||||
"ADMIN_MAIN_PROMO_STATS": "💰 Promo codes / Stats",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Support",
|
||||
"ADMIN_MAIN_MESSAGES": "📨 Messages",
|
||||
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
|
||||
"ADMIN_MAIN_SYSTEM": "🛠️ System",
|
||||
"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_COMMUNICATIONS_WELCOME_TEXT": "👋 Welcome message",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Promo offers",
|
||||
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Promo offers</b>\n\nSelect a template to configure:",
|
||||
"ADMIN_PROMO_OFFER_TEST_ACCESS": "Test servers",
|
||||
"ADMIN_PROMO_OFFER_EXTEND": "Renewal discount",
|
||||
"ADMIN_PROMO_OFFER_PURCHASE": "Purchase discount",
|
||||
"ADMIN_PROMO_OFFER_TYPE": "Type: {label}",
|
||||
"ADMIN_PROMO_OFFER_VALID": "Validity: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_DISCOUNT": "Extra discount: {percent}% (stacks with other discounts)",
|
||||
"ADMIN_PROMO_OFFER_STACKABLE_NOTE": "The discount applies once and stacks with the promo group.",
|
||||
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "After activation the discount lasts for {hours} h.",
|
||||
"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_ALLOWED": "Available segments:",
|
||||
"ADMIN_PROMO_OFFER_PREVIEW": "Preview:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_MESSAGE": "Enter the new offer text:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Enter the button label:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Enter validity (hours):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Enter discount percentage:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_ACTIVE_DURATION": "Enter how long the activated discount should remain valid (hours):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Enter test access duration (hours):",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 <b>Select a squad</b>",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Current squad: {name}",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Current squad: {uuid}",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Current squad: not selected",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Pick a server to grant temporary access from the list below.",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Page {page}/{total}",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Clear",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Back",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Squad updated",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Squad cleared",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Server not found",
|
||||
"ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ No servers available.",
|
||||
"ADMIN_PROMO_OFFER_SENDING": "Starting broadcast...",
|
||||
"ADMIN_PROMO_OFFER_NO_USERS": "No matching users found.",
|
||||
"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_PROMO_OFFER_LOGS": "📜 Activity log",
|
||||
"ADMIN_PROMO_OFFER_LOGS_TITLE": "📜 <b>Promo offer activity log</b>",
|
||||
"ADMIN_PROMO_OFFER_LOGS_PAGINATION": "Page {page}/{total}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY": "No activity yet.",
|
||||
"ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER": "Unknown user",
|
||||
"ADMIN_PROMO_OFFER_LOGS_USER": "👤 {user}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_PERCENT": "📉 Discount: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST": "🧪 Test access",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT": "💸 Discount",
|
||||
"ADMIN_PROMO_OFFER_LOGS_SOURCE": "🏷 Source: {source}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC": "ℹ️ Action: {reason}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL": "💳 Applied during manual payment",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY": "🤖 Used in autopay renewal",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED": "⏳ Offer expired",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED": "⏳ Test access removed",
|
||||
"ADMIN_PROMO_OFFER_LOGS_DESCRIPTION": "📝 {description}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_AMOUNT": "💰 Amount: {amount}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_SQUAD": "🌍 Squad: {squad}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS": "🌍 New squads: {squads}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Claimed",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Used",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Disabled",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER": "👤 Send to user",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TITLE": "👤 <b>Send to a user</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_HINT": "Select a user to deliver the promo offer.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH": "🔍 Search",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT": "Enter name, username or user ID to search:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY": "🔍 Search: <b>{query}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_RESET": "❌ Clear search",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS": "↩️ Back to segment selection",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY": "No matching users found. Adjust your query.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_PROFILE": "👤 <b>{name}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM": "🆔 <code>{telegram_id}</code>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_USERNAME": "🔗 @{username}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_STATUS": "Status: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BALANCE": "Balance: {amount}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION": "💳 <b>Subscription</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS": "Status: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END": "Expires: {date}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN": "not specified",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC": "Traffic: {used}/{limit} GB",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS": "Connected squads: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION": "💳 No active subscription",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT": "💸 Active discount: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL": " (until {date})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT": " (until {date}, {time} left)",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE": " — source: {source}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE": "💸 No active discount",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER": "📨 <b>Selected offer</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE": "Type: {label}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID": "Valid for: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION": "Test access: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT": "Discount: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION": "Active after claim for {hours} h",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS": "📨 Active offers:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST": "Test access",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT": "{percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS": "+{bonus}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY": "no expiry",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM": "• {description} (until {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER": "• {description}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES": " Expires: {expires}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS": " Status: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED": "✅ Claimed",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING": "⏳ Not claimed",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT": " Time left: {time}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED": "expired",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION": " After claim: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION": " Total validity: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER": "📊 Offer stats",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL": "Total sent: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED": "Claimed: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING": "Not claimed: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE": "Active now: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS": "🧪 Active test accesses:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM": "• {squad} (until {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS": "📨 No active offers",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON": "📬 Send offer",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST": "⬅️ Back to users",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE": "👤 Back to profile",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE": "📬 Sent to {name}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SKIPPED": "Skipped: {skipped} (already has access)",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT": "Delivery not performed",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Support tickets",
|
||||
"ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit",
|
||||
"ADMIN_SUPPORT_SETTINGS": "🛟 Support settings",
|
||||
"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",
|
||||
"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_SETTINGS_PRIVACY_POLICY": "🛡️ Privacy policy",
|
||||
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Privacy policy</b>",
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "The privacy policy is shown in the Info section.",
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Policy display is disabled or empty.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Policy is active and visible to users.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Policy is enabled but the text is empty — users won't see it.",
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Last updated: {timestamp}",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Text preview:</b>",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "No text provided yet.",
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Edit text",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 View current text",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Current text (preview):",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Send the new privacy policy text. HTML markup is allowed.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Use /html_help for supported tags.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Privacy policy editing",
|
||||
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Cancel",
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ The policy text is too long. Maximum 4000 characters.",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Privacy policy updated.",
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ Back to policy settings",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "The privacy policy text is not set yet.",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Current policy text</b>",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ The text is shortened for display. Users will see the full version in the menu.",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Policy enabled",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Policy disabled",
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Back to editing",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
|
||||
"ADMIN_SETTINGS_BOT_CONFIG": "🧩 Bot configuration",
|
||||
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Maintenance",
|
||||
"ADMIN_SYSTEM_UPDATES": "📄 Updates",
|
||||
"ADMIN_SYSTEM_BACKUPS": "🗄️ Backups",
|
||||
"ADMIN_SYSTEM_LOGS": "🧾 Logs",
|
||||
"ADMIN_REPORTS_PREVIOUS_DAY": "📆 Previous day",
|
||||
"ADMIN_REPORTS_LAST_WEEK": "🗓️ Last week",
|
||||
"ADMIN_REPORTS_LAST_MONTH": "📅 Last month",
|
||||
"ADMIN_USERS_ALL": "👥 All users",
|
||||
"ADMIN_USERS_SEARCH": "🔍 Search",
|
||||
"ADMIN_USERS_INACTIVE": "🗑️ Inactive",
|
||||
"ADMIN_USERS_FILTERS": "⚙️ Filters",
|
||||
"ADMIN_USERS_FILTER_BALANCE": "💰 By balance",
|
||||
"ADMIN_USERS_FILTER_TRAFFIC": "📶 By traffic",
|
||||
"ADMIN_USERS_FILTER_ACTIVITY": "🕒 By activity",
|
||||
"ADMIN_USERS_FILTER_SPENDING": "💳 By spending",
|
||||
"ADMIN_USERS_FILTER_PURCHASES": "🛒 By purchases",
|
||||
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 By campaign",
|
||||
"ADMIN_SUBSCRIPTIONS_ALL": "📱 All subscriptions",
|
||||
"ADMIN_SUBSCRIPTIONS_EXPIRING": "⏰ Expiring soon",
|
||||
"ADMIN_SUBSCRIPTIONS_PRICING": "⚙️ Pricing settings",
|
||||
"ADMIN_SUBSCRIPTIONS_COUNTRIES": "🌍 Manage countries",
|
||||
"ADMIN_PROMOCODES_ALL": "🎫 All promo codes",
|
||||
"ADMIN_PROMOCODES_CREATE": "➕ Create",
|
||||
"ADMIN_PROMOCODES_GENERAL_STATS": "📊 Overall statistics",
|
||||
"ADMIN_CAMPAIGNS_LIST": "📋 Campaign list",
|
||||
"ADMIN_CAMPAIGNS_CREATE": "➕ Create",
|
||||
"ADMIN_CAMPAIGNS_GENERAL_STATS": "📊 Overall statistics",
|
||||
"ADMIN_CAMPAIGN_DISABLE": "🔴 Disable",
|
||||
"ADMIN_CAMPAIGN_ENABLE": "🟢 Enable",
|
||||
"ADMIN_CAMPAIGN_STATS": "📊 Statistics",
|
||||
"ADMIN_CAMPAIGN_EDIT": "✏️ Edit",
|
||||
"ADMIN_CAMPAIGN_DELETE": "🗑️ Delete",
|
||||
"ADMIN_BACK_TO_LIST": "⬅️ Back to list",
|
||||
"ADMIN_CAMPAIGN_EDIT_NAME": "✏️ Name",
|
||||
"ADMIN_CAMPAIGN_EDIT_START": "🔗 Parameter",
|
||||
"ADMIN_CAMPAIGN_BONUS_BALANCE": "💰 Balance bonus",
|
||||
"ADMIN_CAMPAIGN_DURATION": "📅 Duration",
|
||||
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Traffic",
|
||||
"ADMIN_CAMPAIGN_DEVICES": "📱 Devices",
|
||||
"ADMIN_CAMPAIGN_SERVERS": "🌍 Servers",
|
||||
"ADMIN_CAMPAIGN_BONUS_SUBSCRIPTION": "📱 Subscription bonus",
|
||||
"ADMIN_PROMOCODE_EDIT": "✏️ Edit",
|
||||
"ADMIN_PROMOCODE_TOGGLE": "🔄 Status",
|
||||
"ADMIN_PROMOCODE_STATS": "📊 Statistics",
|
||||
"ADMIN_PROMOCODE_DELETE": "🗑️ Delete",
|
||||
"ADMIN_MESSAGES_ALL_USERS": "📨 All users",
|
||||
"ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 By subscriptions",
|
||||
"ADMIN_MESSAGES_BY_CRITERIA": "🔍 By criteria",
|
||||
"ADMIN_MESSAGES_HISTORY": "📋 History",
|
||||
"ADMIN_MONITORING_START": "▶️ Start",
|
||||
"ADMIN_MONITORING_STOP": "⏸️ Stop",
|
||||
"ADMIN_MONITORING_STATUS": "📊 Status",
|
||||
"ADMIN_MONITORING_LOGS": "📋 Logs",
|
||||
"ADMIN_MONITORING_SETTINGS_BUTTON": "⚙️ Settings",
|
||||
"ADMIN_REMNAWAVE_SYSTEM_STATS": "📊 System statistics",
|
||||
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Manage nodes",
|
||||
"ADMIN_REMNAWAVE_SYNC": "🔄 Synchronization",
|
||||
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Manage squads",
|
||||
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Traffic",
|
||||
"ADMIN_STATS_USERS": "👥 Users",
|
||||
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_STATS_REVENUE": "💰 Revenue",
|
||||
"ADMIN_STATS_REFERRALS": "🤝 Referrals",
|
||||
"ADMIN_STATS_SUMMARY": "📊 Summary",
|
||||
"ADMIN_STATS_BUTTON": "📊 Statistics",
|
||||
"ADMIN_USER_BALANCE": "💰 Balance",
|
||||
"ADMIN_USER_SUBSCRIPTION_SETTINGS": "📱 Subscription & settings",
|
||||
"ADMIN_USER_STATISTICS": "📊 Statistics",
|
||||
"ADMIN_USER_TRANSACTIONS": "📋 Transactions",
|
||||
"ADMIN_USER_BLOCK": "🚫 Block",
|
||||
"ADMIN_USER_DELETE": "🗑️ Delete",
|
||||
"ADMIN_USER_UNBLOCK": "✅ Unblock",
|
||||
"ADMIN_USER_ALREADY_DELETED": "❌ User deleted",
|
||||
"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_PROMOCODE_TYPE_BALANCE": "💰 Balance",
|
||||
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days",
|
||||
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial",
|
||||
"ADMIN_BROADCAST_TARGET_ALL": "👥 Everyone",
|
||||
"ADMIN_BROADCAST_TARGET_ACTIVE": "📱 With subscription",
|
||||
"ADMIN_BROADCAST_TARGET_TRIAL": "🎁 Trial",
|
||||
"ADMIN_BROADCAST_TARGET_NO_SUB": "❌ No subscription",
|
||||
"ADMIN_BROADCAST_TARGET_EXPIRING": "⏰ Expiring",
|
||||
"ADMIN_BROADCAST_TARGET_EXPIRED": "🔚 Expired",
|
||||
"ADMIN_BROADCAST_TARGET_ACTIVE_ZERO": "🧊 Active 0 GB",
|
||||
"ADMIN_BROADCAST_TARGET_TRIAL_ZERO": "🥶 Trial 0 GB",
|
||||
"ADMIN_CRITERIA_TODAY": "📅 Today",
|
||||
"ADMIN_CRITERIA_WEEK": "📅 Last 7 days",
|
||||
"ADMIN_CRITERIA_MONTH": "📅 Last month",
|
||||
"ADMIN_CRITERIA_ACTIVE_TODAY": "⚡ Active today",
|
||||
"ADMIN_CRITERIA_INACTIVE_WEEK": "💤 Inactive 7+ days",
|
||||
"ADMIN_CRITERIA_INACTIVE_MONTH": "💤 Inactive 30+ days",
|
||||
"ADMIN_CRITERIA_REFERRALS": "🤝 Via referrals",
|
||||
"ADMIN_CRITERIA_PROMOCODES": "🎫 Used promo codes",
|
||||
"ADMIN_CRITERIA_DIRECT": "🎯 Direct registration",
|
||||
"ADMIN_HISTORY_REFRESH": "🔄 Refresh",
|
||||
"ADMIN_SYNC_FULL": "🔄 Full sync",
|
||||
"ADMIN_SYNC_ONLY_NEW": "🆕 Only new",
|
||||
"ADMIN_SYNC_UPDATE": "📈 Update data",
|
||||
"ADMIN_SYNC_VALIDATE": "🔍 Validate",
|
||||
"ADMIN_SYNC_CLEANUP": "🧹 Cleanup",
|
||||
"ADMIN_SYNC_RECOMMENDATIONS": "💡 Recommendations",
|
||||
"ADMIN_SYNC_CONFIRM": "✅ Confirm",
|
||||
"ADMIN_SYNC_RETRY": "🔄 Retry",
|
||||
"ADMIN_SYNC_BACK": "⬅️ Back to sync",
|
||||
"ADMIN_BACK_TO_MAIN": "🏠 Back to main menu",
|
||||
"ADMIN_CANCEL": "❌ Cancel",
|
||||
"ADMIN_CONTINUE": "✅ Continue",
|
||||
"ADMIN_PERIOD_TODAY": "📅 Today",
|
||||
"ADMIN_PERIOD_YESTERDAY": "📅 Yesterday",
|
||||
"ADMIN_PERIOD_WEEK": "📅 Week",
|
||||
"ADMIN_PERIOD_MONTH": "📅 Month",
|
||||
"ADMIN_PERIOD_ALL": "📅 All time",
|
||||
"ADMIN_NODE_ENABLE": "▶️ Enable",
|
||||
"ADMIN_NODE_DISABLE": "⏸️ Disable",
|
||||
"ADMIN_NODE_RESTART": "🔄 Restart",
|
||||
"ADMIN_NODE_STATS": "📊 Statistics",
|
||||
"ADMIN_SQUAD_ADD_ALL": "👥 Add all users",
|
||||
"ADMIN_SQUAD_REMOVE_ALL": "❌ Remove all users",
|
||||
"ADMIN_SQUAD_EDIT": "✏️ Edit",
|
||||
"ADMIN_SQUAD_DELETE": "🗑️ Delete squad",
|
||||
"ADMIN_SQUAD_EDIT_INBOUNDS": "🔧 Edit inbounds",
|
||||
"ADMIN_SQUAD_RENAME": "✏️ Rename",
|
||||
"ADMIN_BACK_TO_SQUADS": "⬅️ Back to squads",
|
||||
"ADMIN_MONITORING_STOP_HARD": "⏹️ Stop",
|
||||
"ADMIN_MONITORING_FORCE_CHECK": "🔄 Force check",
|
||||
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications",
|
||||
"ADMIN_MONITORING_STATISTICS": "📊 Statistics",
|
||||
"ADMIN_BACK_TO_ADMIN": "⬅️ Back to admin",
|
||||
"ADMIN_MONITORING_CLEAR_OLD": "🗑️ Clear old",
|
||||
"ADMIN_MONITORING_CLEAR": "🗑️ Clear",
|
||||
"ADMIN_BACK_TO_MONITORING": "⬅️ Back to monitoring",
|
||||
"ADMIN_MONITORING_DELETE_LOG": "🗑️ Delete this log",
|
||||
"ADMIN_MONITORING_BACK_TO_LOGS": "⬅️ Back to log list",
|
||||
"ADMIN_MONITORING_CONFIRM_CLEAR": "✅ Yes, clear",
|
||||
"ADMIN_MONITORING_CLEAR_ALL": "🗑️ Clear ALL logs",
|
||||
"ADMIN_MONITORING_RESTART": "🔄 Restart",
|
||||
"ADMIN_MONITORING_CHECK_NOW": "🔄 Check now",
|
||||
"ADMIN_MONITORING_SET_INTERVAL": "⏱️ Check interval",
|
||||
"ADMIN_MONITORING_NOTIFICATIONS": "🔔 Notifications",
|
||||
"ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Auto-pay settings",
|
||||
"ADMIN_MONITORING_AUTO_CLEANUP": "🧹 Auto-clean logs",
|
||||
"ADMIN_MONITORING_FILTER_SUCCESS": "✅ Success",
|
||||
"ADMIN_MONITORING_FILTER_ERRORS": "❌ Errors",
|
||||
"ADMIN_MONITORING_FILTER_CYCLES": "🔄 Monitoring cycles",
|
||||
"ADMIN_MONITORING_FILTER_AUTOPAY": "💳 Auto-payments",
|
||||
"ADMIN_MONITORING_ALL_LOGS": "📋 All logs",
|
||||
"ADMIN_SERVERS_LIST": "📋 Server list",
|
||||
"ADMIN_SERVERS_SYNC": "🔄 Synchronization",
|
||||
"ADMIN_SERVERS_ADD": "➕ Add server",
|
||||
"ADMIN_SERVERS_STATS": "📊 Statistics",
|
||||
"ADMIN_SERVER_DISABLE": "❌ Disable",
|
||||
"ADMIN_SERVER_ENABLE": "✅ Enable",
|
||||
"ADMIN_SERVER_EDIT_NAME": "✏️ Name",
|
||||
"ADMIN_SERVER_EDIT_PRICE": "💰 Price",
|
||||
"ADMIN_SERVER_EDIT_COUNTRY": "🌍 Country",
|
||||
"ADMIN_SERVER_EDIT_LIMIT": "👥 Limit",
|
||||
"ADMIN_SERVER_EDIT_DESCRIPTION": "📝 Description",
|
||||
"ADMIN_SERVER_DELETE": "🗑️ Delete",
|
||||
"ADMIN_MAINTENANCE_DISABLE": "🟢 Disable maintenance",
|
||||
"ADMIN_MAINTENANCE_ENABLE": "🔧 Enable maintenance",
|
||||
"ADMIN_MAINTENANCE_STOP_MONITORING": "⏹️ Stop monitoring",
|
||||
"ADMIN_MAINTENANCE_START_MONITORING": "▶️ Start monitoring",
|
||||
"ADMIN_MAINTENANCE_CHECK_API": "🔍 Check API",
|
||||
"ADMIN_MAINTENANCE_PANEL_STATUS": "🌐 Panel status",
|
||||
"ADMIN_MAINTENANCE_SEND_NOTIFICATION": "📢 Send notification",
|
||||
"ADMIN_REFRESH": "🔄 Refresh",
|
||||
"ADMIN_WELCOME_DISABLE": "🔴 Disable",
|
||||
"ADMIN_WELCOME_ENABLE": "🟢 Enable",
|
||||
"ADMIN_WELCOME_EDIT": "📝 Edit text",
|
||||
"ADMIN_WELCOME_SHOW": "👁️ Show current",
|
||||
"ADMIN_WELCOME_PREVIEW": "👁️ Preview",
|
||||
"ADMIN_WELCOME_RESET": "🔄 Reset",
|
||||
"ADMIN_WELCOME_HTML": "🏷️ HTML formatting",
|
||||
"ADMIN_WELCOME_PLACEHOLDERS": "💡 Placeholders",
|
||||
"ADMIN_BROADCAST_ADD_PHOTO": "📷 Add photo",
|
||||
"ADMIN_BROADCAST_ADD_VIDEO": "🎥 Add video",
|
||||
"ADMIN_BROADCAST_ADD_DOCUMENT": "📄 Add document",
|
||||
"ADMIN_BROADCAST_SKIP_MEDIA": "⏭️ Skip media",
|
||||
"ADMIN_BROADCAST_USE_MEDIA": "✅ Use this media",
|
||||
"ADMIN_BROADCAST_REPLACE_MEDIA": "🔄 Replace media",
|
||||
"ADMIN_BROADCAST_NO_MEDIA": "⏭️ No media",
|
||||
"ADMIN_BROADCAST_CHANGE_MEDIA": "🖼️ Change media",
|
||||
"ADMIN_BROADCAST_BUTTON_BALANCE": "💰 Top up balance",
|
||||
"ADMIN_BROADCAST_BUTTON_REFERRALS": "🤝 Referrals",
|
||||
"ADMIN_BROADCAST_BUTTON_PROMOCODE": "🎫 Promo code",
|
||||
"ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Connect",
|
||||
"ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Subscription",
|
||||
"ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Support",
|
||||
"ADMIN_BROADCAST_BUTTON_HOME": "🏠 Main menu",
|
||||
"ADMIN_PRICING_SUMMARY_EMPTY": "—",
|
||||
"ADMIN_PRICING_MENU_TITLE": "Pricing management",
|
||||
"ADMIN_PRICING_MENU_DESCRIPTION": "Quick access to subscription plans, traffic bundles and extra services.",
|
||||
"ADMIN_PRICING_MENU_SUMMARY": "Quick summary:",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_CORE": "• Core limits: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_EXTRA": "• Extras: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_PERIOD_OPTIONS": "• Available periods: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_PERIODS": "• Periods: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_TRAFFIC": "• Traffic: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_TRIAL": "• Trial: {summary}",
|
||||
"ADMIN_PRICING_MENU_PROMPT": "Choose a section to edit:",
|
||||
"ADMIN_PRICING_BUTTON_CORE": "⚙️ Core limits",
|
||||
"ADMIN_PRICING_BUTTON_EXTRA": "➕ Extras",
|
||||
"ADMIN_PRICING_BUTTON_PERIOD_OPTIONS": "🗓 Available periods",
|
||||
"ADMIN_PRICING_BUTTON_PERIODS": "🗓 Subscription periods",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC": "📦 Traffic packages",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Package visibility",
|
||||
"ADMIN_PRICING_BUTTON_TRIAL": "🎁 Trial period",
|
||||
"ADMIN_PRICING_CHOICE_ALREADY": "This option is already active.",
|
||||
"ADMIN_PRICING_CHOICE_UPDATED": "Selected: {label}",
|
||||
"ADMIN_PRICING_EDIT_CANCEL": "❌ Cancel",
|
||||
"ADMIN_PRICING_EDIT_CANCEL_HINT": "Send \"Cancel\" to return without changes.",
|
||||
"ADMIN_PRICING_EDIT_TITLE": "Update price",
|
||||
"ADMIN_PRICING_EDIT_TARGET": "Current item",
|
||||
"ADMIN_PRICING_EDIT_CURRENT": "Current value",
|
||||
"ADMIN_PRICING_EDIT_PROMPT": "Enter a new price in RUB (e.g. 990 or 990.50). Use 0 for a free plan.",
|
||||
"ADMIN_PRICING_EDIT_EXPIRED": "Editing session expired.",
|
||||
"ADMIN_PRICING_EDIT_CANCELLED": "Changes cancelled.",
|
||||
"ADMIN_PRICING_EDIT_INVALID": "Could not parse the price. Please enter a number in RUB (e.g. 990 or 990.50).",
|
||||
"ADMIN_PRICING_EDIT_SUCCESS": "Price for {item} updated: {price}",
|
||||
"ADMIN_PRICING_PERIOD_DISABLED": "Period disabled.",
|
||||
"ADMIN_PRICING_PERIOD_ENABLED": "Period enabled.",
|
||||
"ADMIN_PRICING_PERIOD_MIN": "At least one period must remain.",
|
||||
"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_CORE_TITLE": "⚙️ Core limits",
|
||||
"ADMIN_PRICING_SECTION_CURRENT": "Current values:",
|
||||
"ADMIN_PRICING_SECTION_EMPTY": "No values available.",
|
||||
"ADMIN_PRICING_SECTION_EXTRA_TITLE": "➕ Extra options",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_PROMPT": "Tap a period to toggle its visibility.",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_RENEW": "Active renewal periods: {items}",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_SUB": "Active subscription periods: {items}",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_TITLE": "🗓 Available periods",
|
||||
"ADMIN_PRICING_SECTION_PERIODS_TITLE": "🗓 Subscription periods",
|
||||
"ADMIN_PRICING_SECTION_PROMPT": "Select what to update:",
|
||||
"ADMIN_PRICING_SECTION_SETTINGS_GENERIC": "⚙️ Settings",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_TITLE": "📦 Traffic packages",
|
||||
"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.",
|
||||
"ADMIN_PRICING_SECTION_TRIAL_TITLE": "🎁 Trial period",
|
||||
"ADMIN_PRICING_SETTING_CANCEL_HINT": "Reply \"Cancel\" to go back without changes.",
|
||||
"ADMIN_PRICING_SETTING_CURRENT": "Current value",
|
||||
"ADMIN_PRICING_SETTING_EDIT_TITLE": "Parameter configuration",
|
||||
"ADMIN_PRICING_SETTING_EDIT_WITH_VALUE": "✏️ {label} • {value}",
|
||||
"ADMIN_PRICING_SETTING_EXAMPLE": "Example",
|
||||
"ADMIN_PRICING_SETTING_FORMAT": "Input format",
|
||||
"ADMIN_PRICING_SETTING_INVALID": "Could not update the parameter. Please check the value format.",
|
||||
"ADMIN_PRICING_SETTING_PARAMETER": "Parameter",
|
||||
"ADMIN_PRICING_SETTING_PROMPT": "Send a new value or type \"Cancel\". Use none to clear.",
|
||||
"ADMIN_PRICING_SETTING_SUCCESS": "Parameter {label} updated: {value}",
|
||||
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Important",
|
||||
"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_SETTINGS_FAQ": "❓ FAQ",
|
||||
"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_FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"ADMIN_FAQ_DESCRIPTION": "FAQ is shown in the “Info” section.",
|
||||
"ADMIN_FAQ_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_FAQ_PAGE_STATS": "Total pages: {total}",
|
||||
"ADMIN_FAQ_STATUS_DISABLED": "⚠️ FAQ display is turned off.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED": "✅ FAQ is enabled. Active pages: {count}.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED_EMPTY": "⚠️ FAQ is enabled but there are no active pages.",
|
||||
"ADMIN_FAQ_STATUS_EMPTY": "⚠️ FAQ has not been configured yet.",
|
||||
"ADMIN_FAQ_PAGES_EMPTY": "No pages have been created yet.",
|
||||
"ADMIN_FAQ_PAGES_OVERVIEW": "<b>Page list:</b>\n{items}",
|
||||
"ADMIN_FAQ_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_FAQ_ADD_PAGE_BUTTON": "➕ Add page",
|
||||
"ADMIN_FAQ_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_FAQ_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_FAQ_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_FAQ_ENTER_TITLE": "Enter a title for the new FAQ page:",
|
||||
"ADMIN_FAQ_CANCEL_BUTTON": "⬅️ Cancel",
|
||||
"ADMIN_FAQ_TITLE_EMPTY": "❌ Title cannot be empty.",
|
||||
"ADMIN_FAQ_TITLE_TOO_LONG": "❌ Title is too long. Maximum 255 characters.",
|
||||
"ADMIN_FAQ_ENTER_CONTENT": "Send the content of the FAQ page. HTML is allowed.",
|
||||
"ADMIN_FAQ_CONTENT_TOO_LONG": "❌ Text is too long. Maximum 6000 characters.",
|
||||
"ADMIN_FAQ_CONTENT_EMPTY": "❌ Text cannot be empty.",
|
||||
"ADMIN_FAQ_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_FAQ_PAGE_CREATED": "✅ FAQ page created.",
|
||||
"ADMIN_FAQ_BACK_TO_LIST": "⬅️ Back to FAQ settings",
|
||||
"ADMIN_FAQ_ENABLED_ALERT": "✅ FAQ enabled.",
|
||||
"ADMIN_FAQ_DISABLED_ALERT": "🚫 FAQ disabled.",
|
||||
"ADMIN_FAQ_PAGE_NOT_FOUND": "⚠️ Page not found.",
|
||||
"ADMIN_FAQ_PAGE_HEADER": "📄 <b>FAQ page</b>",
|
||||
"ADMIN_FAQ_PAGE_STATUS_ACTIVE": "✅ Active",
|
||||
"ADMIN_FAQ_PAGE_STATUS_INACTIVE": "🚫 Disabled",
|
||||
"ADMIN_FAQ_PAGE_UPDATED": "Updated: {timestamp}",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW_EMPTY": "Content has not been provided yet.",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW": "<b>Preview:</b>\n{content}",
|
||||
"ADMIN_FAQ_PAGE_TITLE": "<b>Title:</b> {title}",
|
||||
"ADMIN_FAQ_PAGE_STATUS": "Status: {status}",
|
||||
"ADMIN_FAQ_EDIT_TITLE_BUTTON": "✏️ Edit title",
|
||||
"ADMIN_FAQ_EDIT_CONTENT_BUTTON": "📝 Edit text",
|
||||
"ADMIN_FAQ_PAGE_ENABLE_BUTTON": "✅ Enable page",
|
||||
"ADMIN_FAQ_PAGE_DISABLE_BUTTON": "🚫 Disable page",
|
||||
"ADMIN_FAQ_PAGE_MOVE_UP": "⬆️ Up",
|
||||
"ADMIN_FAQ_PAGE_MOVE_DOWN": "⬇️ Down",
|
||||
"ADMIN_FAQ_PAGE_DELETE_BUTTON": "🗑️ Delete",
|
||||
"ADMIN_FAQ_TITLE_UPDATED": "✅ Title updated.",
|
||||
"ADMIN_FAQ_UNEXPECTED_STATE": "⚠️ State was reset.",
|
||||
"ADMIN_FAQ_CONTENT_UPDATED": "✅ Page text updated.",
|
||||
"ADMIN_FAQ_PAGE_ENABLED_ALERT": "✅ Page enabled.",
|
||||
"ADMIN_FAQ_PAGE_DISABLED_ALERT": "🚫 Page disabled.",
|
||||
"ADMIN_FAQ_PAGE_DELETED": "🗑️ Page deleted.",
|
||||
"ADMIN_FAQ_PAGE_REORDERED": "✅ Order updated.",
|
||||
"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."
|
||||
}
|
||||
|
||||
+642
-7
@@ -72,7 +72,6 @@
|
||||
"NO_TICKETS_ADMIN": "Нет тикетов для отображения.",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
|
||||
|
||||
"ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!",
|
||||
"TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.",
|
||||
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
|
||||
@@ -187,6 +186,7 @@
|
||||
"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": "✅ Подтвердить",
|
||||
@@ -235,12 +235,42 @@
|
||||
"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": "🔧 Управление устройствами",
|
||||
"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_FAQ": "❓ FAQ",
|
||||
"FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"FAQ_PAGES_PROMPT": "Выберите вопрос:",
|
||||
"FAQ_NOT_AVAILABLE": "FAQ временно недоступен.",
|
||||
"FAQ_PAGE_UNTITLED": "Без названия",
|
||||
"FAQ_PAGE_NOT_AVAILABLE": "Эта страница FAQ недоступна.",
|
||||
"FAQ_PAGE_EMPTY": "Текст для этой страницы ещё не добавлен.",
|
||||
"FAQ_PAGE_TITLE": "<b>{title}</b>",
|
||||
"FAQ_PAGE_FOOTER": "Страница {current} из {total}",
|
||||
"FAQ_BACK_TO_LIST": "⬅️ К списку FAQ",
|
||||
"MENU_PRIVACY_POLICY": "🛡️ Политика конфиденциальности",
|
||||
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
|
||||
"PRIVACY_POLICY_EMPTY_ALERT": "Текст политики конфиденциальности ещё не указан.",
|
||||
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
@@ -275,7 +305,13 @@
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
"PAL24_PAY_BUTTON": "🏦 Оплатить через 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_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||
"PAL24_CARD_PAY_BUTTON": "💳 Оплатить банковской картой (PayPalych)",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n{steps}\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PAL24_INSTRUCTION_BUTTON": "{step}. Нажмите кнопку «{button}»",
|
||||
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
|
||||
"PAL24_INSTRUCTION_CONFIRM": "{step}. Подтвердите перевод",
|
||||
"PAL24_INSTRUCTION_COMPLETE": "{step}. Средства зачислятся автоматически",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
@@ -375,6 +411,8 @@
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"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_SERVER_RANDOM_POOL": "🎲 Случайный из {count} серверов",
|
||||
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Тестовый сервер",
|
||||
"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",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
@@ -521,19 +559,616 @@
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
|
||||
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 <b>Скидка {percent}% активирована!</b> \n\nОна суммируется с другими скидками и автоматически применится при следующей оплате.",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
|
||||
"PROMO_OFFER_CLOSE": "❌ Закрыть",
|
||||
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ Для активации предложения необходима действующая подписка.",
|
||||
"TEST_ACCESS_NO_SQUADS": "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
|
||||
"TEST_ACCESS_REMNAWAVE_ERROR": "❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
|
||||
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Не удалось активировать предложение. Попробуйте позже.",
|
||||
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ Этот сервер уже подключен к вашей подписке.",
|
||||
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
|
||||
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Доступ выдан!",
|
||||
"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>",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):"
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
|
||||
"ADMIN_MAIN_SERVERS": "🌐 Серверы",
|
||||
"ADMIN_MAIN_PRICING": "💰 Цены",
|
||||
"ADMIN_MAIN_PROMO_STATS": "💰 Промокоды/Статистика",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
|
||||
"ADMIN_MAIN_MESSAGES": "📨 Сообщения",
|
||||
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
|
||||
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
|
||||
"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_COMMUNICATIONS_WELCOME_TEXT": "👋 Приветственный текст",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Промо-предложения",
|
||||
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:",
|
||||
"ADMIN_PROMO_OFFER_TEST_ACCESS": "Тестовые сервера",
|
||||
"ADMIN_PROMO_OFFER_EXTEND": "Скидка на продление",
|
||||
"ADMIN_PROMO_OFFER_PURCHASE": "Скидка на покупку",
|
||||
"ADMIN_PROMO_OFFER_TYPE": "Тип: {label}",
|
||||
"ADMIN_PROMO_OFFER_VALID": "Срок действия: {hours} ч",
|
||||
"ADMIN_PROMO_OFFER_DISCOUNT": "Доп. скидка: {percent}% (суммируется с другими скидками)",
|
||||
"ADMIN_PROMO_OFFER_STACKABLE_NOTE": "Скидка применяется один раз и добавляется к промогруппе.",
|
||||
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "Скидка после активации действует {hours} ч.",
|
||||
"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_ALLOWED": "Доступные категории:",
|
||||
"ADMIN_PROMO_OFFER_PREVIEW": "Предпросмотр:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_MESSAGE": "Введите новый текст предложения:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Введите новый текст кнопки:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Укажите срок действия (в часах):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Введите размер скидки в процентах:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_ACTIVE_DURATION": "Укажите срок действия активированной скидки (в часах):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Введите длительность тестового доступа (в часах):",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 <b>Выберите сквад</b>",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Текущий сквад: {name}",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Текущий сквад: {uuid}",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Текущий сквад: не выбран",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Выберите сервер для тестового доступа из списка ниже.",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Страница {page}/{total}",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Очистить",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Назад",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Сквад обновлён",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Сквад очищен",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Сервер не найден",
|
||||
"ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ Доступные серверы не найдены.",
|
||||
"ADMIN_PROMO_OFFER_SENDING": "Начинаем рассылку...",
|
||||
"ADMIN_PROMO_OFFER_NO_USERS": "Подходящих пользователей не найдено.",
|
||||
"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_PROMO_OFFER_LOGS": "📜 Лог операций",
|
||||
"ADMIN_PROMO_OFFER_LOGS_TITLE": "📜 <b>Лог операций промо-предложений</b>",
|
||||
"ADMIN_PROMO_OFFER_LOGS_PAGINATION": "Страница {page}/{total}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY": "Записей пока нет.",
|
||||
"ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER": "Неизвестный пользователь",
|
||||
"ADMIN_PROMO_OFFER_LOGS_USER": "👤 {user}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_PERCENT": "📉 Скидка: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST": "🧪 Тестовый доступ",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT": "💸 Скидка",
|
||||
"ADMIN_PROMO_OFFER_LOGS_SOURCE": "🏷 Источник: {source}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC": "ℹ️ Действие: {reason}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL": "💳 Списано при ручной оплате",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY": "🤖 Применено при автопродлении",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED": "⏳ Предложение истекло",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED": "⏳ Тестовый доступ отключён",
|
||||
"ADMIN_PROMO_OFFER_LOGS_DESCRIPTION": "📝 {description}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_AMOUNT": "💰 Сумма: {amount}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_SQUAD": "🌍 Сквад: {squad}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS": "🌍 Новые сквады: {squads}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Принято",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Использовано",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Отключено",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER": "👤 Отправка пользователю",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TITLE": "👤 <b>Отправка пользователю</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_HINT": "Выберите пользователя для отправки промопредложения.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH": "🔍 Поиск",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT": "Введите имя, username или ID пользователя для поиска:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY": "🔍 Поиск: <b>{query}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_RESET": "❌ Сбросить поиск",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS": "↩️ К выбору категории",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY": "Подходящие пользователи не найдены. Измените запрос поиска.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_PROFILE": "👤 <b>{name}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM": "🆔 <code>{telegram_id}</code>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_USERNAME": "🔗 @{username}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_STATUS": "Статус: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BALANCE": "Баланс: {amount}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION": "💳 <b>Подписка</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS": "Статус: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END": "Истекает: {date}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN": "не указано",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC": "Трафик: {used}/{limit} ГБ",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS": "Подключено сквадов: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION": "💳 Подписка отсутствует",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT": "💸 Активная скидка: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL": " (до {date})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT": " (до {date}, осталось {time})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE": " — источник: {source}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE": "💸 Активная скидка отсутствует",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER": "📨 <b>Выбранное предложение</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE": "Тип: {label}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID": "Действует: {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION": "Тестовый доступ: {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT": "Скидка: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION": "После активации действует {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS": "📨 Активные предложения:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST": "Тестовый доступ",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT": "{percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS": "+{bonus}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY": "без срока",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM": "• {description} (до {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER": "• {description}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES": " Истекает: {expires}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS": " Статус: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED": "✅ Принято",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING": "⏳ Не принято",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT": " Осталось: {time}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED": "истекло",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION": " После активации: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION": " Всего действует: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER": "📊 Статистика предложений",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL": "Всего отправлено: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED": "Принято: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING": "Не принято: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE": "Активно сейчас: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS": "🧪 Активные тестовые доступы:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM": "• {squad} (до {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS": "📨 Активных предложений нет",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON": "📬 Отправить предложение",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST": "⬅️ К списку пользователей",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE": "👤 К профилю пользователя",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE": "📬 Отправка пользователю {name}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT": "Отправка не выполнена",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
|
||||
"ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов",
|
||||
"ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки",
|
||||
"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_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_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
|
||||
"ADMIN_SETTINGS_BOT_CONFIG": "🧩 Конфигурация бота",
|
||||
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Техработы",
|
||||
"ADMIN_SYSTEM_UPDATES": "📄 Обновления",
|
||||
"ADMIN_SYSTEM_BACKUPS": "🗄️ Резервные копии",
|
||||
"ADMIN_SYSTEM_LOGS": "🧾 Логи",
|
||||
"ADMIN_REPORTS_PREVIOUS_DAY": "📆 За вчера",
|
||||
"ADMIN_REPORTS_LAST_WEEK": "🗓️ За неделю",
|
||||
"ADMIN_REPORTS_LAST_MONTH": "📅 За месяц",
|
||||
"ADMIN_USERS_ALL": "👥 Все пользователи",
|
||||
"ADMIN_USERS_SEARCH": "🔍 Поиск",
|
||||
"ADMIN_USERS_INACTIVE": "🗑️ Неактивные",
|
||||
"ADMIN_USERS_FILTERS": "⚙️ Фильтры",
|
||||
"ADMIN_USERS_FILTER_BALANCE": "💰 По балансу",
|
||||
"ADMIN_USERS_FILTER_TRAFFIC": "📶 По трафику",
|
||||
"ADMIN_USERS_FILTER_ACTIVITY": "🕒 По активности",
|
||||
"ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат",
|
||||
"ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок",
|
||||
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании",
|
||||
"ADMIN_SUBSCRIPTIONS_ALL": "📱 Все подписки",
|
||||
"ADMIN_SUBSCRIPTIONS_EXPIRING": "⏰ Истекающие",
|
||||
"ADMIN_SUBSCRIPTIONS_PRICING": "⚙️ Настройки цен",
|
||||
"ADMIN_SUBSCRIPTIONS_COUNTRIES": "🌍 Управление странами",
|
||||
"ADMIN_PROMOCODES_ALL": "🎫 Все промокоды",
|
||||
"ADMIN_PROMOCODES_CREATE": "➕ Создать",
|
||||
"ADMIN_PROMOCODES_GENERAL_STATS": "📊 Общая статистика",
|
||||
"ADMIN_CAMPAIGNS_LIST": "📋 Список кампаний",
|
||||
"ADMIN_CAMPAIGNS_CREATE": "➕ Создать",
|
||||
"ADMIN_CAMPAIGNS_GENERAL_STATS": "📊 Общая статистика",
|
||||
"ADMIN_CAMPAIGN_DISABLE": "🔴 Выключить",
|
||||
"ADMIN_CAMPAIGN_ENABLE": "🟢 Включить",
|
||||
"ADMIN_CAMPAIGN_STATS": "📊 Статистика",
|
||||
"ADMIN_CAMPAIGN_EDIT": "✏️ Редактировать",
|
||||
"ADMIN_CAMPAIGN_DELETE": "🗑️ Удалить",
|
||||
"ADMIN_BACK_TO_LIST": "⬅️ К списку",
|
||||
"ADMIN_CAMPAIGN_EDIT_NAME": "✏️ Название",
|
||||
"ADMIN_CAMPAIGN_EDIT_START": "🔗 Параметр",
|
||||
"ADMIN_CAMPAIGN_BONUS_BALANCE": "💰 Бонус на баланс",
|
||||
"ADMIN_CAMPAIGN_DURATION": "📅 Длительность",
|
||||
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Трафик",
|
||||
"ADMIN_CAMPAIGN_DEVICES": "📱 Устройства",
|
||||
"ADMIN_CAMPAIGN_SERVERS": "🌍 Серверы",
|
||||
"ADMIN_CAMPAIGN_BONUS_SUBSCRIPTION": "📱 Бонус на подписку",
|
||||
"ADMIN_PROMOCODE_EDIT": "✏️ Редактировать",
|
||||
"ADMIN_PROMOCODE_TOGGLE": "🔄 Статус",
|
||||
"ADMIN_PROMOCODE_STATS": "📊 Статистика",
|
||||
"ADMIN_PROMOCODE_DELETE": "🗑️ Удалить",
|
||||
"ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям",
|
||||
"ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 По подпискам",
|
||||
"ADMIN_MESSAGES_BY_CRITERIA": "🔍 По критериям",
|
||||
"ADMIN_MESSAGES_HISTORY": "📋 История",
|
||||
"ADMIN_MONITORING_START": "▶️ Запустить",
|
||||
"ADMIN_MONITORING_STOP": "⏸️ Остановить",
|
||||
"ADMIN_MONITORING_STATUS": "📊 Статус",
|
||||
"ADMIN_MONITORING_LOGS": "📋 Логи",
|
||||
"ADMIN_MONITORING_SETTINGS_BUTTON": "⚙️ Настройки",
|
||||
"ADMIN_REMNAWAVE_SYSTEM_STATS": "📊 Системная статистика",
|
||||
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Управление нодами",
|
||||
"ADMIN_REMNAWAVE_SYNC": "🔄 Синхронизация",
|
||||
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Управление сквадами",
|
||||
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Трафик",
|
||||
"ADMIN_STATS_USERS": "👥 Пользователи",
|
||||
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_STATS_REVENUE": "💰 Доходы",
|
||||
"ADMIN_STATS_REFERRALS": "🤝 Партнерка",
|
||||
"ADMIN_STATS_SUMMARY": "📊 Общая сводка",
|
||||
"ADMIN_STATS_BUTTON": "📊 Статистика",
|
||||
"ADMIN_USER_BALANCE": "💰 Баланс",
|
||||
"ADMIN_USER_SUBSCRIPTION_SETTINGS": "📱 Подписка и настройки",
|
||||
"ADMIN_USER_STATISTICS": "📊 Статистика",
|
||||
"ADMIN_USER_TRANSACTIONS": "📋 Транзакции",
|
||||
"ADMIN_USER_BLOCK": "🚫 Заблокировать",
|
||||
"ADMIN_USER_DELETE": "🗑️ Удалить",
|
||||
"ADMIN_USER_UNBLOCK": "✅ Разблокировать",
|
||||
"ADMIN_USER_ALREADY_DELETED": "❌ Пользователь удален",
|
||||
"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_PROMOCODE_TYPE_BALANCE": "💰 Баланс",
|
||||
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки",
|
||||
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",
|
||||
"ADMIN_BROADCAST_TARGET_ALL": "👥 Всем",
|
||||
"ADMIN_BROADCAST_TARGET_ACTIVE": "📱 С подпиской",
|
||||
"ADMIN_BROADCAST_TARGET_TRIAL": "🎁 Триал",
|
||||
"ADMIN_BROADCAST_TARGET_NO_SUB": "❌ Без подписки",
|
||||
"ADMIN_BROADCAST_TARGET_EXPIRING": "⏰ Истекающие",
|
||||
"ADMIN_BROADCAST_TARGET_EXPIRED": "🔚 Истекшие",
|
||||
"ADMIN_BROADCAST_TARGET_ACTIVE_ZERO": "🧊 Активна 0 ГБ",
|
||||
"ADMIN_BROADCAST_TARGET_TRIAL_ZERO": "🥶 Триал 0 ГБ",
|
||||
"ADMIN_CRITERIA_TODAY": "📅 Сегодня",
|
||||
"ADMIN_CRITERIA_WEEK": "📅 За неделю",
|
||||
"ADMIN_CRITERIA_MONTH": "📅 За месяц",
|
||||
"ADMIN_CRITERIA_ACTIVE_TODAY": "⚡ Активные сегодня",
|
||||
"ADMIN_CRITERIA_INACTIVE_WEEK": "💤 Неактивные 7+ дней",
|
||||
"ADMIN_CRITERIA_INACTIVE_MONTH": "💤 Неактивные 30+ дней",
|
||||
"ADMIN_CRITERIA_REFERRALS": "🤝 Через рефералов",
|
||||
"ADMIN_CRITERIA_PROMOCODES": "🎫 Использовали промокоды",
|
||||
"ADMIN_CRITERIA_DIRECT": "🎯 Прямая регистрация",
|
||||
"ADMIN_HISTORY_REFRESH": "🔄 Обновить",
|
||||
"ADMIN_SYNC_FULL": "🔄 Полная синхронизация",
|
||||
"ADMIN_SYNC_ONLY_NEW": "🆕 Только новые",
|
||||
"ADMIN_SYNC_UPDATE": "📈 Обновить данные",
|
||||
"ADMIN_SYNC_VALIDATE": "🔍 Валидация",
|
||||
"ADMIN_SYNC_CLEANUP": "🧹 Очистка",
|
||||
"ADMIN_SYNC_RECOMMENDATIONS": "💡 Рекомендации",
|
||||
"ADMIN_SYNC_CONFIRM": "✅ Подтвердить",
|
||||
"ADMIN_SYNC_RETRY": "🔄 Повторить",
|
||||
"ADMIN_SYNC_BACK": "⬅️ К синхронизации",
|
||||
"ADMIN_BACK_TO_MAIN": "🏠 В главное меню",
|
||||
"ADMIN_CANCEL": "❌ Отмена",
|
||||
"ADMIN_CONTINUE": "✅ Продолжить",
|
||||
"ADMIN_PERIOD_TODAY": "📅 Сегодня",
|
||||
"ADMIN_PERIOD_YESTERDAY": "📅 Вчера",
|
||||
"ADMIN_PERIOD_WEEK": "📅 Неделя",
|
||||
"ADMIN_PERIOD_MONTH": "📅 Месяц",
|
||||
"ADMIN_PERIOD_ALL": "📅 Все время",
|
||||
"ADMIN_NODE_ENABLE": "▶️ Включить",
|
||||
"ADMIN_NODE_DISABLE": "⏸️ Отключить",
|
||||
"ADMIN_NODE_RESTART": "🔄 Перезагрузить",
|
||||
"ADMIN_NODE_STATS": "📊 Статистика",
|
||||
"ADMIN_SQUAD_ADD_ALL": "👥 Добавить всех пользователей",
|
||||
"ADMIN_SQUAD_REMOVE_ALL": "❌ Удалить всех пользователей",
|
||||
"ADMIN_SQUAD_EDIT": "✏️ Редактировать",
|
||||
"ADMIN_SQUAD_DELETE": "🗑️ Удалить сквад",
|
||||
"ADMIN_SQUAD_EDIT_INBOUNDS": "🔧 Изменить инбаунды",
|
||||
"ADMIN_SQUAD_RENAME": "✏️ Переименовать",
|
||||
"ADMIN_BACK_TO_SQUADS": "⬅️ Назад к сквадам",
|
||||
"ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить",
|
||||
"ADMIN_MONITORING_FORCE_CHECK": "🔄 Принудительная проверка",
|
||||
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений",
|
||||
"ADMIN_MONITORING_STATISTICS": "📊 Статистика",
|
||||
"ADMIN_BACK_TO_ADMIN": "⬅️ Назад в админку",
|
||||
"ADMIN_MONITORING_CLEAR_OLD": "🗑️ Очистить старые",
|
||||
"ADMIN_MONITORING_CLEAR": "🗑️ Очистить",
|
||||
"ADMIN_BACK_TO_MONITORING": "⬅️ Назад к мониторингу",
|
||||
"ADMIN_MONITORING_DELETE_LOG": "🗑️ Удалить этот лог",
|
||||
"ADMIN_MONITORING_BACK_TO_LOGS": "⬅️ К списку логов",
|
||||
"ADMIN_MONITORING_CONFIRM_CLEAR": "✅ Да, очистить",
|
||||
"ADMIN_MONITORING_CLEAR_ALL": "🗑️ Очистить ВСЕ логи",
|
||||
"ADMIN_MONITORING_RESTART": "🔄 Перезапустить",
|
||||
"ADMIN_MONITORING_CHECK_NOW": "🔄 Проверить сейчас",
|
||||
"ADMIN_MONITORING_SET_INTERVAL": "⏱️ Интервал проверки",
|
||||
"ADMIN_MONITORING_NOTIFICATIONS": "🔔 Уведомления",
|
||||
"ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Настройки автооплаты",
|
||||
"ADMIN_MONITORING_AUTO_CLEANUP": "🧹 Автоочистка логов",
|
||||
"ADMIN_MONITORING_FILTER_SUCCESS": "✅ Успешные",
|
||||
"ADMIN_MONITORING_FILTER_ERRORS": "❌ Ошибки",
|
||||
"ADMIN_MONITORING_FILTER_CYCLES": "🔄 Циклы мониторинга",
|
||||
"ADMIN_MONITORING_FILTER_AUTOPAY": "💳 Автооплаты",
|
||||
"ADMIN_MONITORING_ALL_LOGS": "📋 Все логи",
|
||||
"ADMIN_SERVERS_LIST": "📋 Список серверов",
|
||||
"ADMIN_SERVERS_SYNC": "🔄 Синхронизация",
|
||||
"ADMIN_SERVERS_ADD": "➕ Добавить сервер",
|
||||
"ADMIN_SERVERS_STATS": "📊 Статистика",
|
||||
"ADMIN_SERVER_DISABLE": "❌ Отключить",
|
||||
"ADMIN_SERVER_ENABLE": "✅ Включить",
|
||||
"ADMIN_SERVER_EDIT_NAME": "✏️ Название",
|
||||
"ADMIN_SERVER_EDIT_PRICE": "💰 Цена",
|
||||
"ADMIN_SERVER_EDIT_COUNTRY": "🌍 Страна",
|
||||
"ADMIN_SERVER_EDIT_LIMIT": "👥 Лимит",
|
||||
"ADMIN_SERVER_EDIT_DESCRIPTION": "📝 Описание",
|
||||
"ADMIN_SERVER_DELETE": "🗑️ Удалить",
|
||||
"ADMIN_MAINTENANCE_DISABLE": "🟢 Выключить техработы",
|
||||
"ADMIN_MAINTENANCE_ENABLE": "🔧 Включить техработы",
|
||||
"ADMIN_MAINTENANCE_STOP_MONITORING": "⏹️ Остановить мониторинг",
|
||||
"ADMIN_MAINTENANCE_START_MONITORING": "▶️ Запустить мониторинг",
|
||||
"ADMIN_MAINTENANCE_CHECK_API": "🔍 Проверить API",
|
||||
"ADMIN_MAINTENANCE_PANEL_STATUS": "🌐 Статус панели",
|
||||
"ADMIN_MAINTENANCE_SEND_NOTIFICATION": "📢 Отправить уведомление",
|
||||
"ADMIN_REFRESH": "🔄 Обновить",
|
||||
"ADMIN_WELCOME_DISABLE": "🔴 Отключить",
|
||||
"ADMIN_WELCOME_ENABLE": "🟢 Включить",
|
||||
"ADMIN_WELCOME_EDIT": "📝 Изменить текст",
|
||||
"ADMIN_WELCOME_SHOW": "👁️ Показать текущий",
|
||||
"ADMIN_WELCOME_PREVIEW": "👁️ Предпросмотр",
|
||||
"ADMIN_WELCOME_RESET": "🔄 Сбросить",
|
||||
"ADMIN_WELCOME_HTML": "🏷️ HTML форматирование",
|
||||
"ADMIN_WELCOME_PLACEHOLDERS": "💡 Плейсхолдеры",
|
||||
"ADMIN_BROADCAST_ADD_PHOTO": "📷 Добавить фото",
|
||||
"ADMIN_BROADCAST_ADD_VIDEO": "🎥 Добавить видео",
|
||||
"ADMIN_BROADCAST_ADD_DOCUMENT": "📄 Добавить документ",
|
||||
"ADMIN_BROADCAST_SKIP_MEDIA": "⏭️ Пропустить медиа",
|
||||
"ADMIN_BROADCAST_USE_MEDIA": "✅ Использовать это медиа",
|
||||
"ADMIN_BROADCAST_REPLACE_MEDIA": "🔄 Заменить медиа",
|
||||
"ADMIN_BROADCAST_NO_MEDIA": "⏭️ Без медиа",
|
||||
"ADMIN_BROADCAST_CHANGE_MEDIA": "🖼️ Изменить медиа",
|
||||
"ADMIN_BROADCAST_BUTTON_BALANCE": "💰 Пополнить баланс",
|
||||
"ADMIN_BROADCAST_BUTTON_REFERRALS": "🤝 Партнерка",
|
||||
"ADMIN_BROADCAST_BUTTON_PROMOCODE": "🎫 Промокод",
|
||||
"ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Подключиться",
|
||||
"ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Подписка",
|
||||
"ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Техподдержка",
|
||||
"ADMIN_BROADCAST_BUTTON_HOME": "🏠 На главную",
|
||||
"ADMIN_PRICING_SUMMARY_EMPTY": "—",
|
||||
"ADMIN_PRICING_MENU_TITLE": "Управление ценами",
|
||||
"ADMIN_PRICING_MENU_DESCRIPTION": "Быстрый доступ к тарифам подписок, пакетам трафика и дополнительным услугам.",
|
||||
"ADMIN_PRICING_MENU_SUMMARY": "Краткая сводка:",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_CORE": "• Базовые лимиты: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_EXTRA": "• Дополнительно: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_PERIOD_OPTIONS": "• Доступные периоды: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_PERIODS": "• Периоды: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_TRAFFIC": "• Трафик: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_TRIAL": "• Триал: {summary}",
|
||||
"ADMIN_PRICING_MENU_PROMPT": "Выберите раздел для редактирования:",
|
||||
"ADMIN_PRICING_BUTTON_CORE": "⚙️ Базовые лимиты",
|
||||
"ADMIN_PRICING_BUTTON_EXTRA": "➕ Дополнительно",
|
||||
"ADMIN_PRICING_BUTTON_PERIOD_OPTIONS": "🗓 Доступные периоды",
|
||||
"ADMIN_PRICING_BUTTON_PERIODS": "🗓 Периоды подписки",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC": "📦 Пакеты трафика",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Отображение пакетов",
|
||||
"ADMIN_PRICING_BUTTON_TRIAL": "🎁 Пробный период",
|
||||
"ADMIN_PRICING_CHOICE_ALREADY": "Это значение уже активно.",
|
||||
"ADMIN_PRICING_CHOICE_UPDATED": "Выбрано: {label}",
|
||||
"ADMIN_PRICING_EDIT_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PRICING_EDIT_CANCEL_HINT": "Напишите «Отмена», чтобы вернуться без изменений.",
|
||||
"ADMIN_PRICING_EDIT_TITLE": "Изменение цены",
|
||||
"ADMIN_PRICING_EDIT_TARGET": "Текущий тариф",
|
||||
"ADMIN_PRICING_EDIT_CURRENT": "Текущее значение",
|
||||
"ADMIN_PRICING_EDIT_PROMPT": "Введите новую стоимость в рублях (например 990 или 990.50). Для бесплатного тарифа укажите 0.",
|
||||
"ADMIN_PRICING_EDIT_EXPIRED": "Сессия редактирования истекла.",
|
||||
"ADMIN_PRICING_EDIT_CANCELLED": "Изменения отменены.",
|
||||
"ADMIN_PRICING_EDIT_INVALID": "Не удалось распознать цену. Укажите число в рублях (например 990 или 990.50).",
|
||||
"ADMIN_PRICING_EDIT_SUCCESS": "Цена для {item} обновлена: {price}",
|
||||
"ADMIN_PRICING_PERIOD_DISABLED": "Период отключен.",
|
||||
"ADMIN_PRICING_PERIOD_ENABLED": "Период включен.",
|
||||
"ADMIN_PRICING_PERIOD_MIN": "Должен оставаться хотя бы один период.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Пакет включен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Пакет отключен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "Должен оставаться хотя бы один пакет.",
|
||||
"ADMIN_PRICING_SECTION_CORE_TITLE": "⚙️ Базовые лимиты",
|
||||
"ADMIN_PRICING_SECTION_CURRENT": "Текущие значения:",
|
||||
"ADMIN_PRICING_SECTION_EMPTY": "Нет доступных значений.",
|
||||
"ADMIN_PRICING_SECTION_EXTRA_TITLE": "➕ Дополнительные опции",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_PROMPT": "Нажмите на период, чтобы включить или выключить его отображение.",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_RENEW": "Активные периоды продления: {items}",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_SUB": "Активные периоды подписки: {items}",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_TITLE": "🗓 Доступные периоды",
|
||||
"ADMIN_PRICING_SECTION_PERIODS_TITLE": "🗓 Периоды подписки",
|
||||
"ADMIN_PRICING_SECTION_PROMPT": "Выберите что изменить:",
|
||||
"ADMIN_PRICING_SECTION_SETTINGS_GENERIC": "⚙️ Настройки",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_TITLE": "📦 Пакеты трафика",
|
||||
"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": "Нажмите на пакет, чтобы включить или выключить его отображение.",
|
||||
"ADMIN_PRICING_SECTION_TRIAL_TITLE": "🎁 Пробный период",
|
||||
"ADMIN_PRICING_SETTING_CANCEL_HINT": "Чтобы вернуться без изменений, ответьте «Отмена».",
|
||||
"ADMIN_PRICING_SETTING_CURRENT": "Текущее значение",
|
||||
"ADMIN_PRICING_SETTING_EDIT_TITLE": "Настройка параметра",
|
||||
"ADMIN_PRICING_SETTING_EDIT_WITH_VALUE": "✏️ {label} • {value}",
|
||||
"ADMIN_PRICING_SETTING_EXAMPLE": "Пример",
|
||||
"ADMIN_PRICING_SETTING_FORMAT": "Формат ввода",
|
||||
"ADMIN_PRICING_SETTING_INVALID": "Не удалось обновить параметр. Проверьте формат значения.",
|
||||
"ADMIN_PRICING_SETTING_PARAMETER": "Параметр",
|
||||
"ADMIN_PRICING_SETTING_PROMPT": "Отправьте новое значение или напишите «Отмена». Для очистки используйте none.",
|
||||
"ADMIN_PRICING_SETTING_SUCCESS": "Параметр {label} обновлен: {value}",
|
||||
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Важно",
|
||||
"MENU_PUBLIC_OFFER": "📄 Оферта",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
|
||||
"ADMIN_SETTINGS_FAQ": "❓ FAQ",
|
||||
"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_FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"ADMIN_FAQ_DESCRIPTION": "FAQ отображается в разделе «Инфо».",
|
||||
"ADMIN_FAQ_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_FAQ_PAGE_STATS": "Всего страниц: {total}",
|
||||
"ADMIN_FAQ_STATUS_DISABLED": "⚠️ Показ FAQ выключен.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED": "✅ FAQ включён. Активных страниц: {count}.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED_EMPTY": "⚠️ FAQ включён, но нет активных страниц.",
|
||||
"ADMIN_FAQ_STATUS_EMPTY": "⚠️ FAQ ещё не настроен.",
|
||||
"ADMIN_FAQ_PAGES_EMPTY": "Страницы ещё не созданы.",
|
||||
"ADMIN_FAQ_PAGES_OVERVIEW": "<b>Список страниц:</b>\n{items}",
|
||||
"ADMIN_FAQ_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_FAQ_ADD_PAGE_BUTTON": "➕ Добавить страницу",
|
||||
"ADMIN_FAQ_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_FAQ_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_FAQ_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_FAQ_ENTER_TITLE": "Введите заголовок для новой страницы FAQ:",
|
||||
"ADMIN_FAQ_CANCEL_BUTTON": "⬅️ Отмена",
|
||||
"ADMIN_FAQ_TITLE_EMPTY": "❌ Заголовок не может быть пустым.",
|
||||
"ADMIN_FAQ_TITLE_TOO_LONG": "❌ Заголовок слишком длинный. Максимум 255 символов.",
|
||||
"ADMIN_FAQ_ENTER_CONTENT": "Отправьте содержимое страницы FAQ. Допускается HTML.",
|
||||
"ADMIN_FAQ_CONTENT_TOO_LONG": "❌ Текст слишком длинный. Максимум 6000 символов.",
|
||||
"ADMIN_FAQ_CONTENT_EMPTY": "❌ Текст не может быть пустым.",
|
||||
"ADMIN_FAQ_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_FAQ_PAGE_CREATED": "✅ Страница FAQ создана.",
|
||||
"ADMIN_FAQ_BACK_TO_LIST": "⬅️ К настройкам FAQ",
|
||||
"ADMIN_FAQ_ENABLED_ALERT": "✅ FAQ включён.",
|
||||
"ADMIN_FAQ_DISABLED_ALERT": "🚫 FAQ отключён.",
|
||||
"ADMIN_FAQ_PAGE_NOT_FOUND": "⚠️ Страница не найдена.",
|
||||
"ADMIN_FAQ_PAGE_HEADER": "📄 <b>Страница FAQ</b>",
|
||||
"ADMIN_FAQ_PAGE_STATUS_ACTIVE": "✅ Активна",
|
||||
"ADMIN_FAQ_PAGE_STATUS_INACTIVE": "🚫 Выключена",
|
||||
"ADMIN_FAQ_PAGE_UPDATED": "Обновлено: {timestamp}",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW": "<b>Превью:</b>\n{content}",
|
||||
"ADMIN_FAQ_PAGE_TITLE": "<b>Заголовок:</b> {title}",
|
||||
"ADMIN_FAQ_PAGE_STATUS": "Статус: {status}",
|
||||
"ADMIN_FAQ_EDIT_TITLE_BUTTON": "✏️ Изменить заголовок",
|
||||
"ADMIN_FAQ_EDIT_CONTENT_BUTTON": "📝 Изменить текст",
|
||||
"ADMIN_FAQ_PAGE_ENABLE_BUTTON": "✅ Включить страницу",
|
||||
"ADMIN_FAQ_PAGE_DISABLE_BUTTON": "🚫 Выключить страницу",
|
||||
"ADMIN_FAQ_PAGE_MOVE_UP": "⬆️ Выше",
|
||||
"ADMIN_FAQ_PAGE_MOVE_DOWN": "⬇️ Ниже",
|
||||
"ADMIN_FAQ_PAGE_DELETE_BUTTON": "🗑️ Удалить",
|
||||
"ADMIN_FAQ_TITLE_UPDATED": "✅ Заголовок обновлён.",
|
||||
"ADMIN_FAQ_UNEXPECTED_STATE": "⚠️ Состояние сброшено.",
|
||||
"ADMIN_FAQ_CONTENT_UPDATED": "✅ Текст страницы обновлён.",
|
||||
"ADMIN_FAQ_PAGE_ENABLED_ALERT": "✅ Страница включена.",
|
||||
"ADMIN_FAQ_PAGE_DISABLED_ALERT": "🚫 Страница выключена.",
|
||||
"ADMIN_FAQ_PAGE_DELETED": "🗑️ Страница удалена.",
|
||||
"ADMIN_FAQ_PAGE_REORDERED": "✅ Порядок обновлён.",
|
||||
"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": "Редактирование оферты отменено."
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ from app.services.backup_service import backup_service
|
||||
from app.services.reporting_service import reporting_service
|
||||
from app.localization.loader import ensure_locale_templates
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.external_admin_service import ensure_external_admin_token
|
||||
from app.services.broadcast_service import broadcast_service
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -46,12 +48,22 @@ async def main():
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
|
||||
|
||||
try:
|
||||
ensure_locale_templates()
|
||||
except Exception as error:
|
||||
logger.warning("Failed to prepare locale templates: %s", error)
|
||||
timeline = StartupTimeline(logger, "Bedolaga Remnawave Bot")
|
||||
timeline.log_banner(
|
||||
[
|
||||
("Уровень логирования", settings.LOG_LEVEL),
|
||||
("Режим БД", settings.DATABASE_MODE),
|
||||
]
|
||||
)
|
||||
|
||||
async with timeline.stage(
|
||||
"Подготовка локализаций", "🗂️", success_message="Шаблоны локализаций готовы"
|
||||
) as stage:
|
||||
try:
|
||||
ensure_locale_templates()
|
||||
except Exception as error:
|
||||
stage.warning(f"Не удалось подготовить шаблоны локализаций: {error}")
|
||||
logger.warning("Failed to prepare locale templates: %s", error)
|
||||
|
||||
killer = GracefulExit()
|
||||
signal.signal(signal.SIGINT, killer.exit_gracefully)
|
||||
@@ -66,168 +78,290 @@ async def main():
|
||||
polling_task = None
|
||||
web_api_server = None
|
||||
|
||||
try:
|
||||
logger.info("📊 Инициализация базы данных...")
|
||||
await init_db()
|
||||
|
||||
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
|
||||
|
||||
if not skip_migration:
|
||||
logger.info("🔧 Выполняем проверку и миграцию базы данных...")
|
||||
try:
|
||||
migration_success = await run_universal_migration()
|
||||
|
||||
if migration_success:
|
||||
logger.info("✅ Миграция базы данных завершена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск")
|
||||
|
||||
except Exception as migration_error:
|
||||
logger.error(f"❌ Ошибка выполнения миграции: {migration_error}")
|
||||
logger.warning("⚠️ Продолжаем запуск без миграции")
|
||||
else:
|
||||
logger.info("ℹ️ Миграция пропущена (SKIP_MIGRATION=true)")
|
||||
|
||||
logger.info("⚙️ Загрузка конфигурации из БД...")
|
||||
try:
|
||||
await bot_configuration_service.initialize()
|
||||
logger.info("✅ Конфигурация загружена")
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Не удалось загрузить конфигурацию: {error}")
|
||||
summary_logged = False
|
||||
|
||||
logger.info("🤖 Настройка бота...")
|
||||
bot, dp = await setup_bot()
|
||||
try:
|
||||
async with timeline.stage(
|
||||
"Инициализация базы данных", "🗄️", success_message="База данных готова"
|
||||
):
|
||||
await init_db()
|
||||
|
||||
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
|
||||
|
||||
if not skip_migration:
|
||||
async with timeline.stage(
|
||||
"Проверка и миграция базы данных",
|
||||
"🧬",
|
||||
success_message="Миграция завершена успешно",
|
||||
) as stage:
|
||||
try:
|
||||
migration_success = await run_universal_migration()
|
||||
if migration_success:
|
||||
stage.success("Миграция завершена успешно")
|
||||
else:
|
||||
stage.warning(
|
||||
"Миграция завершилась с предупреждениями, запуск продолжится"
|
||||
)
|
||||
logger.warning(
|
||||
"⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск"
|
||||
)
|
||||
except Exception as migration_error:
|
||||
stage.warning(f"Ошибка выполнения миграции: {migration_error}")
|
||||
logger.error(f"❌ Ошибка выполнения миграции: {migration_error}")
|
||||
logger.warning("⚠️ Продолжаем запуск без миграции")
|
||||
else:
|
||||
timeline.add_manual_step(
|
||||
"Проверка и миграция базы данных",
|
||||
"⏭️",
|
||||
"Пропущено",
|
||||
"SKIP_MIGRATION=true",
|
||||
)
|
||||
|
||||
async with timeline.stage(
|
||||
"Загрузка конфигурации из БД",
|
||||
"⚙️",
|
||||
success_message="Конфигурация загружена",
|
||||
) as stage:
|
||||
try:
|
||||
await bot_configuration_service.initialize()
|
||||
except Exception as error:
|
||||
stage.warning(f"Не удалось загрузить конфигурацию: {error}")
|
||||
logger.error(f"❌ Не удалось загрузить конфигурацию: {error}")
|
||||
|
||||
bot = None
|
||||
dp = None
|
||||
async with timeline.stage("Настройка бота", "🤖", success_message="Бот настроен") as stage:
|
||||
bot, dp = await setup_bot()
|
||||
stage.log("Кеш и FSM подготовлены")
|
||||
|
||||
monitoring_service.bot = bot
|
||||
maintenance_service.set_bot(bot)
|
||||
broadcast_service.set_bot(bot)
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
admin_notification_service = AdminNotificationService(bot)
|
||||
version_service.bot = bot
|
||||
version_service.set_notification_service(admin_notification_service)
|
||||
logger.info(f"📄 Сервис версий настроен для репозитория: {version_service.repo}")
|
||||
logger.info(f"📦 Текущая версия: {version_service.current_version}")
|
||||
|
||||
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
|
||||
|
||||
logger.info("🗄️ Инициализация сервиса бекапов...")
|
||||
try:
|
||||
backup_service.bot = bot
|
||||
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
if settings_obj.auto_backup_enabled:
|
||||
await backup_service.start_auto_backup()
|
||||
logger.info("✅ Автобекапы запущены")
|
||||
|
||||
logger.info("✅ Сервис бекапов инициализирован")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}")
|
||||
async with timeline.stage(
|
||||
"Интеграция сервисов",
|
||||
"🔗",
|
||||
success_message="Сервисы подключены",
|
||||
) as stage:
|
||||
admin_notification_service = AdminNotificationService(bot)
|
||||
version_service.bot = bot
|
||||
version_service.set_notification_service(admin_notification_service)
|
||||
stage.log(f"Репозиторий версий: {version_service.repo}")
|
||||
stage.log(f"Текущая версия: {version_service.current_version}")
|
||||
stage.success("Мониторинг, уведомления и рассылки подключены")
|
||||
|
||||
logger.info("📊 Инициализация сервиса отчетов...")
|
||||
try:
|
||||
reporting_service.set_bot(bot)
|
||||
await reporting_service.start()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}")
|
||||
async with timeline.stage(
|
||||
"Сервис бекапов",
|
||||
"🗄️",
|
||||
success_message="Сервис бекапов инициализирован",
|
||||
) as stage:
|
||||
try:
|
||||
backup_service.bot = bot
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
if settings_obj.auto_backup_enabled:
|
||||
await backup_service.start_auto_backup()
|
||||
stage.log(
|
||||
"Автобекапы включены: интервал "
|
||||
f"{settings_obj.backup_interval_hours}ч, запуск {settings_obj.backup_time}"
|
||||
)
|
||||
else:
|
||||
stage.log("Автобекапы отключены настройками")
|
||||
stage.success("Сервис бекапов инициализирован")
|
||||
except Exception as e:
|
||||
stage.warning(f"Ошибка инициализации сервиса бекапов: {e}")
|
||||
logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}")
|
||||
|
||||
async with timeline.stage(
|
||||
"Сервис отчетов",
|
||||
"📊",
|
||||
success_message="Сервис отчетов готов",
|
||||
) as stage:
|
||||
try:
|
||||
reporting_service.set_bot(bot)
|
||||
await reporting_service.start()
|
||||
except Exception as e:
|
||||
stage.warning(f"Ошибка запуска сервиса отчетов: {e}")
|
||||
logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}")
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
|
||||
async with timeline.stage(
|
||||
"Внешняя админка",
|
||||
"🛡️",
|
||||
success_message="Токен внешней админки готов",
|
||||
) as stage:
|
||||
try:
|
||||
bot_user = await bot.get_me()
|
||||
token = await ensure_external_admin_token(
|
||||
bot_user.username,
|
||||
bot_user.id,
|
||||
)
|
||||
if token:
|
||||
stage.log("Токен синхронизирован")
|
||||
else:
|
||||
stage.warning("Не удалось получить токен внешней админки")
|
||||
except Exception as error: # pragma: no cover - защитный блок
|
||||
stage.warning(f"Ошибка подготовки внешней админки: {error}")
|
||||
logger.error("❌ Ошибка подготовки внешней админки: %s", error)
|
||||
|
||||
webhook_needed = (
|
||||
settings.TRIBUTE_ENABLED
|
||||
or settings.is_cryptobot_enabled()
|
||||
or settings.is_mulenpay_enabled()
|
||||
)
|
||||
|
||||
if webhook_needed:
|
||||
enabled_services = []
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
enabled_services.append("Tribute")
|
||||
if settings.is_mulenpay_enabled():
|
||||
enabled_services.append("Mulen Pay")
|
||||
if settings.is_cryptobot_enabled():
|
||||
enabled_services.append("CryptoBot")
|
||||
|
||||
logger.info(f"🌐 Запуск webhook сервера для: {', '.join(enabled_services)}...")
|
||||
webhook_server = WebhookServer(bot)
|
||||
await webhook_server.start()
|
||||
else:
|
||||
logger.info("ℹ️ Tribute и CryptoBot отключены, webhook сервер не запускается")
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info("💳 Запуск YooKassa webhook сервера...")
|
||||
yookassa_server_task = asyncio.create_task(
|
||||
start_yookassa_webhook_server(payment_service)
|
||||
)
|
||||
else:
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
logger.info("💳 Запуск PayPalych webhook сервера...")
|
||||
pal24_server = await start_pal24_webhook_server(payment_service)
|
||||
else:
|
||||
logger.info("ℹ️ PayPalych отключен, webhook сервер не запускается")
|
||||
async with timeline.stage(
|
||||
"Webhook сервисы",
|
||||
"🌐",
|
||||
success_message="Webhook сервера настроены",
|
||||
) as stage:
|
||||
if webhook_needed:
|
||||
enabled_services = []
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
enabled_services.append("Tribute")
|
||||
if settings.is_mulenpay_enabled():
|
||||
enabled_services.append("Mulen Pay")
|
||||
if settings.is_cryptobot_enabled():
|
||||
enabled_services.append("CryptoBot")
|
||||
|
||||
logger.info("📊 Запуск службы мониторинга...")
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
logger.info("🔧 Проверка службы техработ...")
|
||||
if not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
logger.info("🔧 Запуск службы техработ...")
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
else:
|
||||
logger.info("🔧 Служба техработ уже запущена")
|
||||
maintenance_task = None
|
||||
|
||||
if settings.is_version_check_enabled():
|
||||
logger.info("📄 Запуск сервиса проверки версий...")
|
||||
version_check_task = asyncio.create_task(version_service.start_periodic_check())
|
||||
else:
|
||||
logger.info("ℹ️ Проверка версий отключена")
|
||||
|
||||
if settings.is_web_api_enabled():
|
||||
try:
|
||||
from app.webapi import WebAPIServer
|
||||
webhook_server = WebhookServer(bot)
|
||||
await webhook_server.start()
|
||||
stage.log(f"Активированы: {', '.join(enabled_services)}")
|
||||
stage.success("Webhook сервера запущены")
|
||||
else:
|
||||
stage.skip("Tribute, Mulen Pay и CryptoBot отключены")
|
||||
|
||||
web_api_server = WebAPIServer()
|
||||
await web_api_server.start()
|
||||
logger.info(
|
||||
"🌐 Административное веб-API запущено: http://%s:%s",
|
||||
settings.WEB_API_HOST,
|
||||
settings.WEB_API_PORT,
|
||||
async with timeline.stage(
|
||||
"YooKassa webhook",
|
||||
"💳",
|
||||
success_message="YooKassa webhook запущен",
|
||||
) as stage:
|
||||
if settings.is_yookassa_enabled():
|
||||
yookassa_server_task = asyncio.create_task(
|
||||
start_yookassa_webhook_server(payment_service)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Не удалось запустить веб-API: {error}")
|
||||
else:
|
||||
logger.info("ℹ️ Веб-API отключено")
|
||||
stage.log(
|
||||
f"Endpoint: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}"
|
||||
)
|
||||
else:
|
||||
stage.skip("YooKassa отключена настройками")
|
||||
|
||||
logger.info("📄 Запуск polling...")
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("🎯 Активные webhook endpoints:")
|
||||
async with timeline.stage(
|
||||
"PayPalych webhook",
|
||||
"💳",
|
||||
success_message="PayPalych webhook запущен",
|
||||
) as stage:
|
||||
if settings.is_pal24_enabled():
|
||||
pal24_server = await start_pal24_webhook_server(payment_service)
|
||||
stage.log(
|
||||
f"Endpoint: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
|
||||
)
|
||||
else:
|
||||
stage.skip("PayPalych отключен настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"Служба мониторинга",
|
||||
"📈",
|
||||
success_message="Служба мониторинга запущена",
|
||||
) as stage:
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
stage.log(f"Интервал опроса: {settings.MONITORING_INTERVAL}с")
|
||||
|
||||
async with timeline.stage(
|
||||
"Служба техработ",
|
||||
"🛡️",
|
||||
success_message="Служба техработ запущена",
|
||||
) as stage:
|
||||
if not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
stage.log(f"Интервал проверки: {settings.MAINTENANCE_CHECK_INTERVAL}с")
|
||||
else:
|
||||
maintenance_task = None
|
||||
stage.skip("Служба техработ уже активна")
|
||||
|
||||
async with timeline.stage(
|
||||
"Сервис проверки версий",
|
||||
"📄",
|
||||
success_message="Проверка версий запущена",
|
||||
) as stage:
|
||||
if settings.is_version_check_enabled():
|
||||
version_check_task = asyncio.create_task(version_service.start_periodic_check())
|
||||
stage.log(
|
||||
f"Интервал проверки: {settings.VERSION_CHECK_INTERVAL_HOURS}ч"
|
||||
)
|
||||
else:
|
||||
version_check_task = None
|
||||
stage.skip("Проверка версий отключена настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"Административное веб-API",
|
||||
"🌐",
|
||||
success_message="Веб-API запущено",
|
||||
) as stage:
|
||||
if settings.is_web_api_enabled():
|
||||
try:
|
||||
from app.webapi import WebAPIServer
|
||||
|
||||
web_api_server = WebAPIServer()
|
||||
await web_api_server.start()
|
||||
stage.success(
|
||||
f"Доступно на http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}"
|
||||
)
|
||||
except Exception as error:
|
||||
stage.warning(f"Не удалось запустить веб-API: {error}")
|
||||
logger.error(f"❌ Не удалось запустить веб-API: {error}")
|
||||
else:
|
||||
stage.skip("Веб-API отключено")
|
||||
|
||||
async with timeline.stage(
|
||||
"Запуск polling",
|
||||
"🤖",
|
||||
success_message="Aiogram polling запущен",
|
||||
) as stage:
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
|
||||
stage.log("skip_updates=True")
|
||||
|
||||
webhook_lines = []
|
||||
if webhook_needed:
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
webhook_lines.append(
|
||||
f"Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(f" Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}")
|
||||
webhook_lines.append(
|
||||
f"Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
webhook_lines.append(
|
||||
f"CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
if settings.is_pal24_enabled():
|
||||
logger.info(
|
||||
f" PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
|
||||
webhook_lines.append(
|
||||
f"YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}"
|
||||
)
|
||||
logger.info("📄 Активные фоновые сервисы:")
|
||||
logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}")
|
||||
logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}")
|
||||
logger.info(f" Проверка версий: {'Включен' if version_check_task else 'Отключен'}")
|
||||
logger.info(
|
||||
" Отчеты: %s",
|
||||
"Включен" if reporting_service.is_running() else "Отключен",
|
||||
if settings.is_pal24_enabled():
|
||||
webhook_lines.append(
|
||||
f"PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
|
||||
)
|
||||
|
||||
timeline.log_section(
|
||||
"Активные webhook endpoints",
|
||||
webhook_lines if webhook_lines else ["Нет активных endpoints"],
|
||||
icon="🎯",
|
||||
)
|
||||
logger.info("=" * 50)
|
||||
|
||||
services_lines = [
|
||||
f"Мониторинг: {'Включен' if monitoring_task else 'Отключен'}",
|
||||
f"Техработы: {'Включен' if maintenance_task else 'Отключен'}",
|
||||
f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}",
|
||||
f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}",
|
||||
]
|
||||
timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄")
|
||||
|
||||
timeline.log_summary()
|
||||
summary_logged = True
|
||||
|
||||
try:
|
||||
while not killer.exit:
|
||||
@@ -276,6 +410,9 @@ async def main():
|
||||
raise
|
||||
|
||||
finally:
|
||||
if not summary_logged:
|
||||
timeline.log_summary()
|
||||
summary_logged = True
|
||||
logger.info("🛑 Начинается корректное завершение работы...")
|
||||
|
||||
if yookassa_server_task and not yookassa_server_task.done():
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"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。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"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": "打开应用并连接到服务器"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"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。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
+2997
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,703 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#2481cc">
|
||||
<title>Connecting to VPN...</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--tg-theme-bg-color: #ffffff;
|
||||
--tg-theme-text-color: #000000;
|
||||
--tg-theme-hint-color: #999999;
|
||||
--tg-theme-link-color: #2481cc;
|
||||
--tg-theme-button-color: #2481cc;
|
||||
--tg-theme-button-text-color: #ffffff;
|
||||
--tg-theme-secondary-bg-color: #f0f0f0;
|
||||
|
||||
--primary: var(--tg-theme-button-color);
|
||||
--primary-rgb: 36, 129, 204;
|
||||
--text-primary: var(--tg-theme-text-color);
|
||||
--text-secondary: var(--tg-theme-hint-color);
|
||||
--bg-primary: var(--tg-theme-bg-color);
|
||||
--bg-secondary: var(--tg-theme-secondary-bg-color);
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--info: #3b82f6;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: rgba(30, 41, 59, 0.85);
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--border-color: rgba(148, 163, 184, 0.25);
|
||||
--shadow-sm: 0 2px 8px rgba(2, 6, 23, 0.3);
|
||||
--shadow-md: 0 4px 16px rgba(2, 6, 23, 0.45);
|
||||
--shadow-lg: 0 8px 32px rgba(2, 6, 23, 0.55);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Language Switcher */
|
||||
.top-bar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.language-select {
|
||||
padding: 8px 32px 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.language-select:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 16px;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.logo-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transform: rotate(45deg);
|
||||
animation: shimmer 3s infinite;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 40px;
|
||||
color: white;
|
||||
z-index: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Main Card */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.card.error {
|
||||
border: 2px solid var(--danger);
|
||||
background: linear-gradient(135deg, var(--bg-secondary), rgba(239, 68, 68, 0.05));
|
||||
}
|
||||
|
||||
.card.error .logo-container {
|
||||
background: linear-gradient(135deg, var(--danger), rgba(239, 68, 68, 0.7));
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid var(--bg-secondary);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Timer */
|
||||
.timer-container {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
border: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.timer-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.timer-number {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timer-unit {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary), rgba(var(--primary-rgb), 0.6));
|
||||
border-radius: 3px;
|
||||
transition: width 1s linear;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Status Text */
|
||||
.status-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8));
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* Hidden */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.timer-number {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Adjustments */
|
||||
:root[data-theme="dark"] .timer-container {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .btn-primary {
|
||||
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.45);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top Bar -->
|
||||
<div class="top-bar">
|
||||
<select id="languageSelect" class="language-select">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
</select>
|
||||
<button class="theme-toggle" id="themeToggle">
|
||||
<svg class="icon-sun" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 18a6 6 0 100-12 6 6 0 000 12zM12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
<svg class="icon-moon" width="20" height="20" fill="currentColor" viewBox="0 0 24 24" style="display: none;">
|
||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header animate-in">
|
||||
<div class="logo-container">
|
||||
<div class="logo-icon" id="appIcon">⚡</div>
|
||||
</div>
|
||||
<div class="logo" data-i18n="app.name">VPN</div>
|
||||
<div class="subtitle" id="appSubtitle" data-i18n="app.connecting">Connecting to VPN...</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Card -->
|
||||
<div class="card animate-in" id="mainCard">
|
||||
<div class="loading" id="loadingState">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text" id="statusText" data-i18n="status.connecting">Establishing secure connection...</div>
|
||||
|
||||
<div class="timer-container" id="timerContainer">
|
||||
<div class="timer-label" data-i18n="timer.label">Redirecting in</div>
|
||||
<div class="timer-display">
|
||||
<span class="timer-number" id="timer">10</span>
|
||||
<span class="timer-unit" data-i18n="timer.seconds">seconds</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status-description" data-i18n="status.manual">If nothing happens, click the button below.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State (hidden by default) -->
|
||||
<div class="error-state hidden" id="errorState">
|
||||
<div class="status-text error-text" data-i18n="error.title">Connection Error</div>
|
||||
<p class="status-description" data-i18n="error.description">The connection link is missing or invalid.</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<a class="btn btn-primary" id="actionButton" href="#" target="_blank" rel="noopener">
|
||||
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span data-i18n="button.connect">Connect to VPN</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// App schemes configuration
|
||||
const appSchemes = [
|
||||
{ scheme: 'happ://', id: 'happ', icon: 'H', name: { en: 'Happ', ru: 'Happ' } },
|
||||
{ scheme: 'flclash://', id: 'flclash', icon: 'F', name: { en: 'FlClash', ru: 'FlClash' } },
|
||||
{ scheme: 'clash://', id: 'clash-meta', icon: 'C', name: { en: 'Clash Meta', ru: 'Clash Meta' } },
|
||||
{ scheme: 'sing-box://', id: 'sing-box', icon: 'S', name: { en: 'Sing-box', ru: 'Sing-box' } },
|
||||
{ scheme: 'v2rayng://', id: 'v2rayng', icon: 'V', name: { en: 'v2rayNG', ru: 'v2rayNG' } },
|
||||
{ scheme: 'sub://', id: 'shadowrocket', icon: 'R', name: { en: 'Shadowrocket', ru: 'Shadowrocket' } },
|
||||
{ scheme: 'hiddify://', id: 'hiddify', icon: 'H', name: { en: 'Hiddify', ru: 'Hiddify' } }
|
||||
];
|
||||
|
||||
// Translations
|
||||
const translations = {
|
||||
en: {
|
||||
'app.name': 'VPN',
|
||||
'app.connecting': 'Connecting to VPN...',
|
||||
'status.connecting': 'Establishing secure connection...',
|
||||
'timer.label': 'Redirecting in',
|
||||
'timer.seconds': 'seconds',
|
||||
'status.manual': 'If nothing happens, click the button below.',
|
||||
'button.connect': 'Connect to VPN',
|
||||
'error.title': 'Connection Error',
|
||||
'error.description': 'The connection link is missing or invalid. Please contact support.'
|
||||
},
|
||||
ru: {
|
||||
'app.name': 'VPN',
|
||||
'app.connecting': 'Подключение к VPN...',
|
||||
'status.connecting': 'Установка безопасного соединения...',
|
||||
'timer.label': 'Перенаправление через',
|
||||
'timer.seconds': 'секунд',
|
||||
'status.manual': 'Если ничего не происходит, нажмите кнопку ниже.',
|
||||
'button.connect': 'Подключиться к VPN',
|
||||
'error.title': 'Ошибка подключения',
|
||||
'error.description': 'Ссылка для подключения отсутствует или недействительна. Обратитесь в поддержку.'
|
||||
}
|
||||
};
|
||||
|
||||
// Global variables
|
||||
let currentLanguage = 'en';
|
||||
let currentTheme = 'light';
|
||||
let timerValue = 10;
|
||||
let timerId = null;
|
||||
let redirectTo = '';
|
||||
let appInfo = null;
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSettings();
|
||||
|
||||
// Get redirect URL from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
redirectTo = urlParams.get('redirect_to') || '';
|
||||
|
||||
// Detect app from URL scheme
|
||||
if (redirectTo) {
|
||||
appInfo = appSchemes.find(a => redirectTo.startsWith(a.scheme));
|
||||
}
|
||||
|
||||
updateUI();
|
||||
|
||||
if (redirectTo) {
|
||||
startTimer();
|
||||
} else {
|
||||
showError();
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved settings
|
||||
function loadSettings() {
|
||||
const savedTheme = localStorage.getItem('remnawave-miniapp-theme') || 'light';
|
||||
const savedLang = localStorage.getItem('remnawave-miniapp-language') || getBrowserLanguage();
|
||||
|
||||
currentTheme = savedTheme;
|
||||
currentLanguage = savedLang;
|
||||
|
||||
applyTheme(savedTheme);
|
||||
document.getElementById('languageSelect').value = currentLanguage;
|
||||
}
|
||||
|
||||
// Get browser language
|
||||
function getBrowserLanguage() {
|
||||
const lang = navigator.language || navigator.userLanguage;
|
||||
if (lang.startsWith('ru')) return 'ru';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
updateThemeToggle();
|
||||
}
|
||||
|
||||
// Update theme toggle icon
|
||||
function updateThemeToggle() {
|
||||
const sunIcon = document.querySelector('.icon-sun');
|
||||
const moonIcon = document.querySelector('.icon-moon');
|
||||
|
||||
if (currentTheme === 'dark') {
|
||||
sunIcon.style.display = 'none';
|
||||
moonIcon.style.display = 'block';
|
||||
} else {
|
||||
sunIcon.style.display = 'block';
|
||||
moonIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle theme
|
||||
document.getElementById('themeToggle').addEventListener('click', function() {
|
||||
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('remnawave-miniapp-theme', currentTheme);
|
||||
applyTheme(currentTheme);
|
||||
});
|
||||
|
||||
// Change language
|
||||
document.getElementById('languageSelect').addEventListener('change', function(e) {
|
||||
currentLanguage = e.target.value;
|
||||
localStorage.setItem('remnawave-miniapp-language', currentLanguage);
|
||||
updateUI();
|
||||
});
|
||||
|
||||
// Get translation
|
||||
function t(key) {
|
||||
return translations[currentLanguage]?.[key] || translations.en[key] || key;
|
||||
}
|
||||
|
||||
// Update UI with translations
|
||||
function updateUI() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
|
||||
// Update app-specific info
|
||||
if (appInfo && redirectTo) {
|
||||
const appName = appInfo.name[currentLanguage] || appInfo.name.en;
|
||||
document.getElementById('appIcon').textContent = appInfo.icon;
|
||||
document.getElementById('appSubtitle').textContent = `${t('app.connecting').replace('VPN', appName)}`;
|
||||
|
||||
const statusText = document.getElementById('statusText');
|
||||
if (statusText) {
|
||||
statusText.textContent = `${t('status.connecting').replace('...', '')} ${appName}...`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update action button
|
||||
const actionButton = document.getElementById('actionButton');
|
||||
if (actionButton && redirectTo) {
|
||||
actionButton.href = redirectTo;
|
||||
}
|
||||
}
|
||||
|
||||
// Start countdown timer
|
||||
function startTimer() {
|
||||
if (!redirectTo || timerId) return;
|
||||
|
||||
const timerEl = document.getElementById('timer');
|
||||
const progressEl = document.getElementById('progressFill');
|
||||
const totalTime = 10;
|
||||
|
||||
timerEl.textContent = timerValue;
|
||||
|
||||
timerId = setInterval(() => {
|
||||
timerValue--;
|
||||
timerEl.textContent = timerValue;
|
||||
|
||||
// Update progress bar
|
||||
const progress = ((totalTime - timerValue) / totalTime) * 100;
|
||||
progressEl.style.width = `${100 - progress}%`;
|
||||
|
||||
if (timerValue <= 0) {
|
||||
clearInterval(timerId);
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
function showError() {
|
||||
document.getElementById('loadingState').classList.add('hidden');
|
||||
document.getElementById('errorState').classList.remove('hidden');
|
||||
document.getElementById('actionButton').classList.add('hidden');
|
||||
document.getElementById('mainCard').classList.add('error');
|
||||
document.getElementById('appIcon').textContent = '!';
|
||||
|
||||
// Update subtitle
|
||||
document.getElementById('appSubtitle').textContent = t('error.title');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user