Compare commits
845 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1001156b4 | |||
| ba2c534622 | |||
| 72f06a784e | |||
| 04e862b6bd | |||
| 7f4af89d60 | |||
| f68bb699eb | |||
| c1d19e2ade | |||
| e4c7e8b27f | |||
| c63c483b9b | |||
| 4b04696feb | |||
| 9ffbba7259 | |||
| cc0b927388 | |||
| cbcf2d55da | |||
| f7c70f60e1 | |||
| 7c96b0f71c | |||
| 2002032be3 | |||
| ed6ff293ea | |||
| 65e66353b2 | |||
| 4fa87804d9 | |||
| 17587a9b30 | |||
| 37c552c705 | |||
| 95c6e72f1b | |||
| 4fa49e6f2e | |||
| 3aaa3d0671 | |||
| 9c013f19c4 | |||
| c7b6929dec | |||
| 4dca932ec2 | |||
| 997fcf8ec7 | |||
| eb8c8ec4ce | |||
| f4ed61c4ca | |||
| 70d3434952 | |||
| 450d75227b | |||
| 6e61980fa9 | |||
| 777e243bcd | |||
| 46d9c25968 | |||
| e26223a7d8 | |||
| 2098165229 | |||
| c7e9d84585 | |||
| 8a92d5e5a9 | |||
| 8b8f187cb3 | |||
| 02e3faf194 | |||
| 7e8e05a0fc | |||
| c3ca12a8a5 | |||
| d82b24927b | |||
| 7d9d05b1e8 | |||
| cc96919672 | |||
| 1f0f5a4317 | |||
| cd9218ec09 | |||
| 7256a0d1ab | |||
| e2f5191844 | |||
| d964a2d53c | |||
| 7ace06834b | |||
| 0a789d3bd1 | |||
| 9c44c3ebd0 | |||
| 67650666fc | |||
| b5e343cfb0 | |||
| 68f72a88a4 | |||
| a225540c48 | |||
| 3fdc1fdeda | |||
| 0748e9eed3 | |||
| f420e07a24 | |||
| 885a56ecf4 | |||
| 4e4e80f3c4 | |||
| 4942ac4c5e | |||
| 20f83ac751 | |||
| 6b2b20cca2 | |||
| 9db392b410 | |||
| 444e87d70e | |||
| d8a6b6b08c | |||
| 2658c298f0 | |||
| 0ca4f9630a | |||
| 718c9aba92 | |||
| 078f1f8878 | |||
| c76eeea020 | |||
| f919368d0b | |||
| ccb442fe44 | |||
| d8c0dd862b | |||
| 1ed418b176 | |||
| 59efd837cd | |||
| 60d23bedde | |||
| d08f74179f | |||
| 70ad084bcd | |||
| 818ef1ccb0 | |||
| f6369f7b44 | |||
| 5f8295a588 | |||
| 5c51788dc3 | |||
| 24e6d57273 | |||
| be95d34122 | |||
| 359cdd106d | |||
| 0bc5b10a78 | |||
| efd4eda62a | |||
| d4c169e6bb | |||
| c12f3d443c | |||
| db7ecea006 | |||
| 2b4136faa7 | |||
| ca09ce8cf4 | |||
| 533087b333 | |||
| 80afcbb737 | |||
| 845faf037f | |||
| 1f160ced02 | |||
| 07e4c07d6e | |||
| 78ab84b021 | |||
| db5551df23 | |||
| ee75395c31 | |||
| 453bb75b7f | |||
| 88d2c8325d | |||
| 8fe9a3dbe2 | |||
| cf6c282b25 | |||
| 773e525a6a | |||
| a7d35d51b8 | |||
| 46eb93dabe | |||
| 43e63f864e | |||
| 8774b53570 | |||
| 2aaec2ca58 | |||
| 5efe36878d | |||
| b9861249b7 | |||
| 2c9b585f4b | |||
| dd5b36ff4a | |||
| 144a942a97 | |||
| ff661d84ee | |||
| ab1b8619f8 | |||
| 0f621bfe5a | |||
| c344f418c5 | |||
| 400cc5a32e | |||
| b6a383b5d8 | |||
| a8215ddec5 | |||
| 954c96ed16 | |||
| ed9a3613f5 | |||
| 29743351de | |||
| fd897464f5 | |||
| d9b0420202 | |||
| 527d907035 | |||
| 6c3270e6d7 | |||
| b7382eb7c3 | |||
| 0cd7d45338 | |||
| 23dcb9699c | |||
| 9603952b2a | |||
| 299eeee9d3 | |||
| 63c7e9c08b | |||
| 42131457bf | |||
| a7370fbe52 | |||
| 41f0a15cea | |||
| dd92f37fc1 | |||
| 46d4d699dd | |||
| 76b594363b | |||
| 107b18d642 | |||
| 9973491503 | |||
| 418081048e | |||
| f22838b99f | |||
| 9bdacea568 | |||
| 6bec0ed0c8 | |||
| 4363955443 | |||
| 47a272c7e8 | |||
| 07ce852917 | |||
| a6ada5c9e3 | |||
| 90c5b1d003 | |||
| 326aca31d7 | |||
| 4fd5ba863b | |||
| 4bd0b221e7 | |||
| f47511f26a | |||
| 2d409eaa1f | |||
| 9d42c4fb7d | |||
| d3ced8f7e2 | |||
| a5715b2ca8 | |||
| c18dd604d8 | |||
| c2656f4ee6 | |||
| 18641d46d3 | |||
| 7ef3a08215 | |||
| 55f1a3b220 | |||
| e2f313e6ea | |||
| 5f99478433 | |||
| 89f0a3663d | |||
| e53dab87e3 | |||
| 6d85c179bb | |||
| 30dafcef49 | |||
| 32d7a7394e | |||
| 3cb4f27433 | |||
| 50692566d5 | |||
| fbb2afd9e1 | |||
| 3de951b153 | |||
| 1b86d467b5 | |||
| e4743ced9b | |||
| 2351056d56 | |||
| 748f9a2393 | |||
| ada15065ea | |||
| 53f1831f59 | |||
| 860ebf18b6 | |||
| ae05ca08ea | |||
| 88e8a56526 | |||
| fb4d714441 | |||
| 2690f63a82 | |||
| ec9855a0a9 | |||
| 984ad8e448 | |||
| ca81b32502 | |||
| 70f190501b | |||
| 432efdbb7c | |||
| 83a821a591 | |||
| 14bdfd4a1e | |||
| 66ff2a38d3 | |||
| 9f7c9cad38 | |||
| 2132226b15 | |||
| 849465b2e1 | |||
| 402b31625b | |||
| 5fc24dbfae | |||
| 8ba65cc2fe | |||
| ee2bffe08e | |||
| c4b5db9002 | |||
| 1a78bdc194 | |||
| a3bb749a6a | |||
| 626f78b230 | |||
| 5d44285c0a | |||
| 2d8091ddcb | |||
| fa6a4698ab | |||
| f40454313f | |||
| 8a256b1bc2 | |||
| 71ac7ea464 | |||
| bbc9f93429 | |||
| 3ba9598b5e | |||
| ffa51986a8 | |||
| 7e20140a00 | |||
| 50d080f54e | |||
| 29829b1c8c | |||
| e84e707d2f | |||
| c12566ae53 | |||
| e6976b5262 | |||
| 81cfdcf7ac | |||
| 9c1ca5b748 | |||
| a0d14f97f4 | |||
| 8fdc70fdc3 | |||
| d93139a458 | |||
| b78dc12db9 | |||
| 2a02f589bf | |||
| bcfa4f8400 | |||
| 042dc900ef | |||
| 6004607bf6 | |||
| 40e4b1ce7f | |||
| a4c5a2295f | |||
| 3d9a75ff51 | |||
| 1216a4dd9d | |||
| 4bc1c402e9 | |||
| 42a758bd68 | |||
| 07a0d0ca43 | |||
| f53b04444b | |||
| 9877c00c92 | |||
| 14aa175193 | |||
| 562766a637 | |||
| 4616911189 | |||
| 1e14a0919f | |||
| e1f0bd152c | |||
| c10e34ad43 | |||
| 714f742560 | |||
| dc5e440fe2 | |||
| d53873dc7f | |||
| 10ab557d8d | |||
| 81147d42a9 | |||
| 8d5e2037d3 | |||
| 7f03e61b5f | |||
| 870ee2fb66 | |||
| a525079282 | |||
| 39aff2bc1b | |||
| b4dc9f1970 | |||
| 37ac3e7a81 | |||
| e3e6428d2a | |||
| 3e3385d1eb | |||
| 0375e74227 | |||
| b6bdeeefdd | |||
| 24b3660f7b | |||
| da2c30cd8d | |||
| 1fad95c0bb | |||
| 6a1b1a9a91 | |||
| 9abf0b6ebe | |||
| 34e90b335b | |||
| e84c82f29d | |||
| 1c929a1a70 | |||
| 1c8878e651 | |||
| 84645d84f7 | |||
| 0cc59501bc | |||
| ef6aaa58ea | |||
| 942ce48241 | |||
| 625ec43c34 | |||
| f184303415 | |||
| 84a17864cd | |||
| 1476247519 | |||
| 0ea95005ee | |||
| ba808f66da | |||
| e8472a502f | |||
| 7d552ab715 | |||
| 00de3a41df | |||
| 36f38187d9 | |||
| dd68b2b788 | |||
| 7a9d37c884 | |||
| 6a904d4be2 | |||
| b5b8408055 | |||
| 86a3228579 | |||
| 560993c0f3 | |||
| a3fe935149 | |||
| a75549aebf | |||
| be3fc7aa09 | |||
| 6cbfc6d74b | |||
| 7ff7a051d9 | |||
| 9a2f156158 | |||
| 38b058636c | |||
| 32523f5dea | |||
| ff70e38dbc | |||
| 8110f52bc3 | |||
| a5a0176433 | |||
| 2d960e3e30 | |||
| b5fa978023 | |||
| 9cc49e9f66 | |||
| dbf63e518f | |||
| 3e218b6e6d | |||
| 2f52f6b8de | |||
| 5d377cf885 | |||
| 62ecacc3cc | |||
| 4ab6d45e50 | |||
| d1bed5e41a | |||
| b907f884ba | |||
| 83a76c928d | |||
| 90d3f3aef2 | |||
| 263df606d2 | |||
| 2a38969e3d | |||
| 300feec18e | |||
| 7c56452eb4 | |||
| 548ec540f2 | |||
| bceba34cc3 | |||
| b07ce2be5c | |||
| 9472cf5e8c | |||
| 82f34e8c6e | |||
| 02b718dac5 | |||
| a65a23acb2 | |||
| 12523e7515 | |||
| d3497f924a | |||
| 75faa54b85 | |||
| d22f9aaf62 | |||
| 68f5e2ab80 | |||
| 4e2b71cb85 | |||
| e229a7ee65 | |||
| abf1f53218 | |||
| 2859219f80 | |||
| e71b7f4533 | |||
| 5ba652fab6 | |||
| 0a3913a2aa | |||
| 118ef8a29f | |||
| b5f7e06605 | |||
| d258d27406 | |||
| 554fb28cdb | |||
| 808901c38b | |||
| 68508d8a86 | |||
| 0daf8d7691 | |||
| 5aeb2038c5 | |||
| d3a1060117 | |||
| 4d22e99076 | |||
| 879607982e | |||
| 5482b31fcc | |||
| f5be0e6807 | |||
| c3a60871c9 | |||
| 2628444866 | |||
| c1a7360c66 | |||
| b8c2bdc9e9 | |||
| 573735ffa9 | |||
| 9b3a9883b0 | |||
| 8cc54d376a | |||
| 26910dbbcc | |||
| b131fea7d1 | |||
| 84bc36d855 | |||
| ea5bd22c28 | |||
| e9f1064d73 | |||
| f3cba203dd | |||
| 0ff15850b7 | |||
| 383563c399 | |||
| 40bbb54f56 | |||
| a09eafb9c1 | |||
| 98b15d5c82 | |||
| b5cb547fe2 | |||
| a7af39fb34 | |||
| 02fa7de016 | |||
| b14ee9a6a4 | |||
| be2181c4cb | |||
| 6cd65812ac | |||
| b282fd1525 | |||
| 7c38e672e3 | |||
| c2685476b0 | |||
| a80e2176de | |||
| bf4de82442 | |||
| 22c736a41c | |||
| a1c5f9d987 | |||
| 1ca1df1403 | |||
| be3b554cde | |||
| efc8f04836 | |||
| 1cf7138430 | |||
| 64c8145f1a | |||
| dc62ad927d | |||
| 61188d8a97 | |||
| f92d5e996e | |||
| 817fb82476 | |||
| 9635f9464e | |||
| 33e0d58e4d | |||
| 5fb951328d | |||
| 5f5255d107 | |||
| 4bf7f83f1e | |||
| cce6d99a74 | |||
| 4060425ac4 | |||
| 5023bec388 | |||
| e0d90874bd | |||
| dd62ba1efd | |||
| ceb8191b43 | |||
| da2e55bb25 | |||
| 8a3a990e39 | |||
| e3cd2b0b92 | |||
| 1777128590 | |||
| 04a5fe9d77 | |||
| 595a4eb3a3 | |||
| ede9259347 | |||
| d87556fd9f | |||
| 198ce23625 | |||
| 1be83b7b48 | |||
| 2b2b94936f | |||
| 84df8f727e | |||
| 84aaaf6254 | |||
| ce867efaea | |||
| 492d9f5170 | |||
| dcbbf7ba44 | |||
| 04e7f818c3 | |||
| 2713bdf08e | |||
| b2bfaf0e81 | |||
| 25a4be076b | |||
| cbd721a10f | |||
| 3ff8cb5e61 | |||
| 6f144f64b1 | |||
| 93200d1450 | |||
| 3f60533c50 | |||
| 41ab0abac8 | |||
| a5e4fb24ca | |||
| 6d2ce895e4 | |||
| f41a4197b6 | |||
| 200c728342 | |||
| d8571c4f3e | |||
| 233ceaf400 | |||
| e8bca6926a | |||
| 9e8aecae4d | |||
| e75b44695e | |||
| 18784b3ef7 | |||
| 98a241a301 | |||
| 70ee783a7a | |||
| 00cd0bb5b0 | |||
| 02b7365ba3 | |||
| a6da530848 | |||
| f6e6af5e42 | |||
| 6f834aeec0 | |||
| 8bfdde0d51 | |||
| 137ac3c58c | |||
| d7a0d9f7a5 | |||
| 9b477a700d | |||
| 77a9dcf5af | |||
| 1c1f7ade78 | |||
| 4aa593f8c6 | |||
| cefd39f7e9 | |||
| cfeb8fd9a3 | |||
| 241a053252 | |||
| b53726276c | |||
| 7316e4aa48 | |||
| 889629e3c1 | |||
| fbecf5bf7a | |||
| faae87212e | |||
| bfa355e956 | |||
| a2c0001f5a | |||
| 5fa2339018 | |||
| 9ba183b6a6 | |||
| 6d142a7f38 | |||
| 6f157e2923 | |||
| dccb070376 | |||
| f0b70c9424 | |||
| 9a847ec6b6 | |||
| 4a94d6a966 | |||
| ae654452e4 | |||
| 2e71fd89c9 | |||
| caf497ed28 | |||
| bb5dd7176b | |||
| ac83a65d3b | |||
| 147cbfa625 | |||
| 497775c382 | |||
| 3a9b8dc0f8 | |||
| 0fd3452dd6 | |||
| 1d5c277df0 | |||
| 6483efc099 | |||
| 149a9f599d | |||
| 1bd55c4d80 | |||
| ef9abe8f91 | |||
| 461b820ef1 | |||
| 0535353124 | |||
| 19804607ac | |||
| 272de0bd33 | |||
| b53a54a237 | |||
| e57c6d0ade | |||
| 18556f54d3 | |||
| a5c081887e | |||
| 80efd4ab86 | |||
| 3df541689b | |||
| 06c90dc3ca | |||
| d3599a6a0c | |||
| afab5f2515 | |||
| 37e7e74d28 | |||
| 25eff6929b | |||
| 72888ff02d | |||
| 9c572a2771 | |||
| 0f6d6256a2 | |||
| 2191e63803 | |||
| 43d75dc3ec | |||
| 59cae2fde6 | |||
| 4f4c9e5426 | |||
| df03f16a9d | |||
| 57db753218 | |||
| 5d4a2b51ef | |||
| 67c6185f8b | |||
| e3d18f7a1d | |||
| 4bcefaa7b8 | |||
| 9d9551c072 | |||
| 4e6bc5ae71 | |||
| c353c69180 | |||
| 9c0e489f3b | |||
| d4028a9d9f | |||
| 25960c180f | |||
| 926bd0cc55 | |||
| 0d714b4dff | |||
| f5ccc1f103 | |||
| 565e6d963b | |||
| c057d1e3d7 | |||
| 10c3151d9e | |||
| cbf88226b4 | |||
| 4ad53ca762 | |||
| a2ace90898 | |||
| 814e38ef59 | |||
| 871e1ae675 | |||
| 67c973124a | |||
| 42cfc3745d | |||
| ccb4dfa267 | |||
| 8a9606db97 | |||
| c3846a998a | |||
| 0bcaab3c50 | |||
| 6dc5150804 | |||
| 7bba18673a | |||
| de8853bd7c | |||
| 6e938b2693 | |||
| cb41f9263f | |||
| fe3f837a58 | |||
| 65374e38b3 | |||
| 24fd4ef842 | |||
| 5682971722 | |||
| c286c44041 | |||
| 1dc3013376 | |||
| ea61734f3a | |||
| ec521b84e7 | |||
| 62189f8642 | |||
| a8e1085884 | |||
| 2bd76d118a | |||
| b258bd95bd | |||
| ab566986c7 | |||
| 033230f73e | |||
| 063d77928c | |||
| ff71f4b637 | |||
| 88f4b2b2fa | |||
| b64bda64dc | |||
| dfeea949dd | |||
| 54d798f358 | |||
| 4546450587 | |||
| ec6b6e6b09 | |||
| cb5d4df4f0 | |||
| 1a060759f9 | |||
| 44fc2fd771 | |||
| 92dbf3269b | |||
| c6b4de05f3 | |||
| ae3c34c2fe | |||
| 34c19ff041 | |||
| 80c9857475 | |||
| c147a6c010 | |||
| 88a720543e | |||
| e780e3731d | |||
| 853cda603e | |||
| 5f8287f389 | |||
| 8fa7cbc060 | |||
| fe1b0fcac3 | |||
| 6f9a1a907d | |||
| 82bb72fc82 | |||
| 6812ff84b9 | |||
| 3096985466 | |||
| ce9e6d4379 | |||
| 1f376d4b5d | |||
| cfb830a694 | |||
| c072b4a932 | |||
| 9fe67eaeb2 | |||
| 389501239c | |||
| 9512813b58 | |||
| bebf636b4e | |||
| 1db1acf44d | |||
| be58a1c04e | |||
| fbdc98f8bd | |||
| ba79cf292f | |||
| 93b870a139 | |||
| cfa50739d8 | |||
| 86eedf1985 | |||
| 1bc7a1c143 | |||
| d6241d31f8 | |||
| a6465b0b0b | |||
| d1b28b669f | |||
| 68452974f1 | |||
| 09810d0b6c | |||
| 7358c4e3fa | |||
| 7380f4a15e | |||
| dbb11957ae | |||
| 151b72baff | |||
| d716e261b4 | |||
| 7ba21d058c | |||
| bbc64f685e | |||
| 2c28fda5e3 | |||
| 5a9ae02f61 | |||
| da6d971de4 | |||
| b4e7dfd099 | |||
| 39577b31bd | |||
| 6b039c1cb0 | |||
| 2cbff15837 | |||
| 921a36c94d | |||
| 14412871bf | |||
| afed43571d | |||
| 773dc11888 | |||
| e0f6dd4097 | |||
| 711ad05983 | |||
| 2d0a9f334b | |||
| 97996048ae | |||
| 66bd7f758e | |||
| c51158bad1 | |||
| ccf881c054 | |||
| 1f780a0ae8 | |||
| 17ac08af8f | |||
| 214488cf80 | |||
| c6c842adc9 | |||
| 4cb23dc2b1 | |||
| 7c9285061a | |||
| 0edfa973e4 | |||
| 0be20d514e | |||
| e414ae40d4 | |||
| b54b2a2dc2 | |||
| f545c0d7ad | |||
| 18142201bd | |||
| 50320ba868 | |||
| 12b05a7d0a | |||
| 7e19a5a1ff | |||
| 096e3a1629 | |||
| 51a5c4a65e | |||
| e5472b4c67 | |||
| ebf4c07142 | |||
| 4ab8aac38f | |||
| 8d32b1463a | |||
| ec09142445 | |||
| 1e3c713c95 | |||
| 1747170ec3 | |||
| 1e436189b7 | |||
| a247485a38 | |||
| b352cb2701 | |||
| b0b2c3f8d8 | |||
| aba98031f7 | |||
| 66ec241c48 | |||
| 2ee80b41e0 | |||
| c1aa08b266 | |||
| 7b7bdc3d88 | |||
| 71588fda63 | |||
| d4b1b20016 | |||
| 952face2ae | |||
| fa8c7bd460 | |||
| de8c506618 | |||
| 18a7d179f8 | |||
| 56c6e97874 | |||
| b574c01264 | |||
| 6d64ba8ffd | |||
| 6d0a85b7be | |||
| 33d5da9530 | |||
| 2a92004fbf | |||
| 97ec8594d9 | |||
| 713a2cd924 | |||
| 0d4fd3d6e9 | |||
| 7cc9d8dc18 | |||
| f896d59609 | |||
| 707858900a | |||
| de0b361062 | |||
| 111b038fec | |||
| a2f90d5de0 | |||
| 084363b3d6 | |||
| 9c0dfa7aa3 | |||
| c4bf18cf0f | |||
| 9663eb4931 | |||
| da846df924 | |||
| a5bb663f9e | |||
| b234905860 | |||
| f658cfcd03 | |||
| 56297cde91 | |||
| f51c74d09e | |||
| b06da932cd | |||
| f869b73bf5 | |||
| 4f40cb8862 | |||
| 161e9b575c | |||
| 42ccd3f3c1 | |||
| 5dcb37032e | |||
| e42bf2de73 | |||
| 4225404673 | |||
| cbfa388568 | |||
| d471a84125 | |||
| 2cc18422fd | |||
| 4ba60dab16 | |||
| 588e4acbd3 | |||
| 3a67bbe298 | |||
| eb730ecd6e | |||
| 112da2c873 | |||
| 37ff3c0d17 | |||
| 1451bfac12 | |||
| c445e24ff6 | |||
| bb5af66656 | |||
| 85e8b7fc24 | |||
| c4802fe442 | |||
| 91ee1d96b3 | |||
| 19fe40b172 | |||
| 355ac7a0fa | |||
| 361a8a6297 | |||
| 0b68aa85c7 | |||
| 3105b811d4 | |||
| b438b6df61 | |||
| 15bda0560a | |||
| 3433ea6aef | |||
| 745ebf64ef | |||
| 80ff06ef24 | |||
| f270ac9f9d | |||
| 7d5d89302b | |||
| 155482779e | |||
| d1bacf140c | |||
| 55a38a5e05 | |||
| f21d451c13 | |||
| 29c3e00c27 | |||
| 44d249ad5b | |||
| 4333412169 | |||
| 50c7eaa29a | |||
| b2e168dbec | |||
| a1d4e9c7b3 | |||
| 82d1b48314 | |||
| eaffaa11b7 | |||
| 2c13995625 | |||
| 7b0892e379 | |||
| 483ccab3f7 | |||
| 7f62af6955 | |||
| 857dfc7e27 | |||
| f47ca40974 | |||
| 0256203f44 | |||
| 5bda94794e | |||
| 0d218980f7 | |||
| 73d8c30d9c | |||
| 7abafbeaa1 | |||
| 2a7728a76c | |||
| d266997720 | |||
| d6fb26d425 | |||
| 1eb3658ce5 | |||
| badf679bc3 | |||
| 1e542a0aa1 | |||
| cdbeca245f | |||
| 5a75626625 | |||
| 17e3985671 | |||
| 5a5f151b38 | |||
| 97c8ddf4e1 | |||
| df7414b670 | |||
| 8dfe2f2934 | |||
| 3f3968dda2 | |||
| 3930564f9b | |||
| 90edc1dc49 | |||
| c9f7906e10 | |||
| 1758eabe0e | |||
| 7ec73c9bc7 | |||
| 1b0ad8f8b6 | |||
| 555f76a4ec | |||
| bfb9c5d09c | |||
| 9a72a7e3e7 | |||
| b48c3f1235 | |||
| 85f59566a6 | |||
| 1c3f2c7500 | |||
| 47ca096e48 | |||
| a9da2a0e63 | |||
| aaea98049e | |||
| 93668da99f | |||
| 53c7df0b8e | |||
| 86e8ac39c3 | |||
| a9a10e3e90 | |||
| 1de81a6aa4 | |||
| 540a0e35a1 | |||
| f0ae702933 | |||
| 19be4348aa | |||
| 84ed1fc5ca | |||
| de38f8219c | |||
| 12061f0752 | |||
| a5174f10d5 | |||
| 69eeaf6c46 | |||
| ac25e1b4c6 | |||
| 26e0800d70 | |||
| d0197aacd7 | |||
| b097c5db44 | |||
| 302f64ad5c | |||
| 23cd2097da | |||
| 58257a6eac | |||
| ea7b982b7b | |||
| 1fcd76db9e | |||
| 66d92cfa51 | |||
| 9dc5ee22ee | |||
| b5df729d97 | |||
| 56e3541b80 | |||
| 9681bce540 | |||
| 7330c729e0 | |||
| 977a19a4c9 | |||
| d226fedf75 | |||
| c9ee1da728 | |||
| d6753501e7 | |||
| c3f62a4ff6 | |||
| 48101eee39 | |||
| 16e771f1c3 | |||
| 0aa49a6a94 | |||
| c810619a13 | |||
| 817d277091 | |||
| d3a822e8b3 | |||
| ec8c37744a | |||
| 732c36630c | |||
| 498d2f04b6 | |||
| d167147725 | |||
| 19800971d6 | |||
| 4b457240b9 | |||
| b63c5867d4 | |||
| 598dd8b6c1 | |||
| 6cd399cea7 | |||
| 466033d04c | |||
| 99bca0220b | |||
| a10b9f0ad7 | |||
| 825d549822 | |||
| 03cb06e757 | |||
| 113b33e68d | |||
| f3a327c9c7 | |||
| 2ca4a989e2 | |||
| 7339f2cd4f | |||
| 827897b771 | |||
| eb5064d591 | |||
| 177862f178 | |||
| 7276dcf1e0 | |||
| 6d613cab85 | |||
| a0b6aa13a3 |
+80
-2
@@ -8,11 +8,17 @@ ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
# Автоматические отчеты
|
||||
ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
|
||||
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
@@ -83,8 +89,13 @@ MAX_DEVICES_LIMIT=15
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# Плюсовать дни триала при покупке платной подписке
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID=false
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
# Сбрасывать трафик при каждой оплате подписки
|
||||
RESET_TRAFFIC_ON_PAYMENT=false
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
@@ -113,6 +124,11 @@ PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
@@ -215,7 +231,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=true
|
||||
CRYPTOBOT_ENABLED=false
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
@@ -226,6 +242,35 @@ CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# MULENPAY
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
MULENPAY_SECRET_KEY=
|
||||
MULENPAY_SHOP_ID=<ID магазина>
|
||||
# необязательно, есть дефолтные значения
|
||||
MULENPAY_BASE_URL=https://mulenpay.ru/api
|
||||
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
|
||||
MULENPAY_DESCRIPTION="Пополнение баланса"
|
||||
MULENPAY_LANGUAGE=ru
|
||||
MULENPAY_VAT_CODE=0
|
||||
MULENPAY_PAYMENT_SUBJECT=4
|
||||
MULENPAY_PAYMENT_MODE=4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
PAL24_API_TOKEN=
|
||||
PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
PAL24_REQUEST_TIMEOUT=30
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
@@ -240,10 +285,24 @@ HIDE_SUBSCRIPTION_LINK=false
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
|
||||
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
|
||||
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
|
||||
|
||||
# Параметры режима happ_cryptolink
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
|
||||
HAPP_DOWNLOAD_LINK_IOS=
|
||||
HAPP_DOWNLOAD_LINK_ANDROID=
|
||||
HAPP_DOWNLOAD_LINK_MACOS=
|
||||
HAPP_DOWNLOAD_LINK_WINDOWS=
|
||||
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
|
||||
|
||||
# Пропустить принятие правил использования бота
|
||||
SKIP_RULES_ACCEPT=false
|
||||
@@ -261,6 +320,23 @@ NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
MONITORING_LOGS_RETENTION_DAYS=30
|
||||
NOTIFICATION_CACHE_HOURS=24
|
||||
|
||||
# ===== СТАТУС СЕРВЕРОВ =====
|
||||
# Режимы: disabled, external_link, external_link_miniapp, xray
|
||||
SERVER_STATUS_MODE=disabled
|
||||
# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp)
|
||||
SERVER_STATUS_EXTERNAL_URL=
|
||||
# URL метрик XrayChecker (для режима xray)
|
||||
SERVER_STATUS_METRICS_URL=
|
||||
# Данные Basic Auth (опционально)
|
||||
SERVER_STATUS_METRICS_USERNAME=
|
||||
SERVER_STATUS_METRICS_PASSWORD=
|
||||
# Проверять SSL сертификат при запросе метрик
|
||||
SERVER_STATUS_METRICS_VERIFY_SSL=true
|
||||
# Таймаут запроса к метрикам (в секундах)
|
||||
SERVER_STATUS_REQUEST_TIMEOUT=10
|
||||
# Количество серверов на странице в режиме интеграции
|
||||
SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
@@ -271,6 +347,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.6-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.2-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.6-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.2-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.6-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.2-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.6-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.2-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.6-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.2-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.6-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.2-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
!docs/
|
||||
!docs/**
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.3.6"
|
||||
ARG VERSION="v2.4.2"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8081 8082 8083
|
||||
EXPOSE 8080 8081 8082
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers)
|
||||
|
||||
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💬 Поддержка](#-поддержка-и-сообщество)
|
||||
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + P2P
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + PayPalych + P2P
|
||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
|
||||
@@ -108,6 +108,24 @@ docker compose logs
|
||||
| 🛡️ **REMNAWAVE_SECRET_KEY** | Ключ защиты панели | `secret_name:secret_value` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
|
||||
### 🌐 Интеграция веб-админки
|
||||
|
||||
Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md).
|
||||
|
||||
### 📱 Telegram Mini App с подпиской
|
||||
|
||||
Инструкция по развёртыванию мини-приложения, публикации статической страницы и настройке reverse-proxy доступна в [docs/miniapp-setup.md](docs/miniapp-setup.md).
|
||||
|
||||
### 📊 Статус серверов в главном меню
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
|------------|----------|--------|
|
||||
| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link` (открывает ссылку в браузере), `external_link_miniapp` (открывает ссылку во встроенном мини-приложении Telegram) или `xray` (интеграция с XrayChecker). | `xray` |
|
||||
| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг (используется в режимах `external_link` и `external_link_miniapp`). | `https://status.example.com` |
|
||||
| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker (Prometheus формат). | `https://sub.example.com/metrics` |
|
||||
| `SERVER_STATUS_METRICS_USERNAME` / `SERVER_STATUS_METRICS_PASSWORD` | Данные Basic Auth, если страница метрик защищена паролем. | `status` / `secret` |
|
||||
| `SERVER_STATUS_ITEMS_PER_PAGE` | Количество серверов, показываемых на одной странице в режиме интеграции. | `10` |
|
||||
|
||||
### 🛡️ Защита панели Remnawave
|
||||
|
||||
Для панелей, защищенных через [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy):
|
||||
@@ -159,6 +177,12 @@ PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
@@ -232,11 +256,17 @@ ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
# Автоматические отчеты
|
||||
ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
|
||||
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
@@ -258,6 +288,7 @@ POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# SQLite настройки (для локального запуска)
|
||||
SQLITE_PATH=./data/bot.db
|
||||
LOCALES_PATH=./locales
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
@@ -284,6 +315,11 @@ REMNAWAVE_SECRET_KEY=
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# Режим удаления пользователей из панели RemnaWave
|
||||
# delete - полностью удалить пользователя из панели
|
||||
# disable - только деактивировать пользователя
|
||||
REMNAWAVE_USER_DELETE_MODE=delete
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
@@ -303,6 +339,8 @@ DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
# Сбрасывать трафик при каждой оплате подписки
|
||||
RESET_TRAFFIC_ON_PAYMENT=false
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
@@ -331,6 +369,11 @@ PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
@@ -416,6 +459,13 @@ YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
@@ -426,7 +476,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=true
|
||||
CRYPTOBOT_ENABLED=false
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
@@ -437,6 +487,35 @@ CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# MULENPAY
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
MULENPAY_SECRET_KEY=
|
||||
MULENPAY_SHOP_ID=<ID магазина>
|
||||
# необязательно, есть дефолтные значения
|
||||
MULENPAY_BASE_URL=https://mulenpay.ru/api
|
||||
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
|
||||
MULENPAY_DESCRIPTION="Пополнение баланса"
|
||||
MULENPAY_LANGUAGE=ru
|
||||
MULENPAY_VAT_CODE=0
|
||||
MULENPAY_PAYMENT_SUBJECT=4
|
||||
MULENPAY_PAYMENT_MODE=4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
PAL24_API_TOKEN=
|
||||
PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
PAL24_REQUEST_TIMEOUT=30
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
@@ -451,10 +530,24 @@ HIDE_SUBSCRIPTION_LINK=false
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
|
||||
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
|
||||
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
|
||||
|
||||
# Параметры режима happ_cryptolink
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
|
||||
HAPP_DOWNLOAD_LINK_IOS=
|
||||
HAPP_DOWNLOAD_LINK_ANDROID=
|
||||
HAPP_DOWNLOAD_LINK_MACOS=
|
||||
HAPP_DOWNLOAD_LINK_WINDOWS=
|
||||
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
|
||||
|
||||
# Пропустить принятие правил использования бота
|
||||
SKIP_RULES_ACCEPT=false
|
||||
@@ -472,6 +565,23 @@ NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
MONITORING_LOGS_RETENTION_DAYS=30
|
||||
NOTIFICATION_CACHE_HOURS=24
|
||||
|
||||
# ===== СТАТУС СЕРВЕРОВ =====
|
||||
# Режимы: disabled, external_link, external_link_miniapp, xray
|
||||
SERVER_STATUS_MODE=disabled
|
||||
# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp)
|
||||
SERVER_STATUS_EXTERNAL_URL=
|
||||
# URL метрик XrayChecker (для режима xray)
|
||||
SERVER_STATUS_METRICS_URL=
|
||||
# Данные Basic Auth (опционально)
|
||||
SERVER_STATUS_METRICS_USERNAME=
|
||||
SERVER_STATUS_METRICS_PASSWORD=
|
||||
# Проверять SSL сертификат при запросе метрик
|
||||
SERVER_STATUS_METRICS_VERIFY_SSL=true
|
||||
# Таймаут запроса к метрикам (в секундах)
|
||||
SERVER_STATUS_REQUEST_TIMEOUT=10
|
||||
# Количество серверов на странице в режиме интеграции
|
||||
SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
@@ -482,6 +592,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
@@ -519,8 +631,6 @@ LOG_FILE=logs/bot.log
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -552,6 +662,8 @@ WEBHOOK_PATH=/webhook
|
||||
- ⭐ Telegram Stars
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa (включая СБП и онлайн-чек)
|
||||
- 💳 MulenPay
|
||||
- 💳 PayPalych (Pal24)
|
||||
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- Детальная история транзакций и чеков
|
||||
@@ -577,7 +689,7 @@ WEBHOOK_PATH=/webhook
|
||||
|
||||
📊 **Мощная аналитика**
|
||||
- 👥 Детальная статистика пользователей и подписок
|
||||
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, CryptoBot)
|
||||
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot)
|
||||
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
|
||||
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
|
||||
|
||||
@@ -668,6 +780,64 @@ WEBHOOK_PATH=/webhook
|
||||
---
|
||||
|
||||
|
||||
## 💻 Локальная разработка
|
||||
|
||||
### 🚀 Быстрый запуск для разработки
|
||||
|
||||
Для локальной разработки используйте специальный Docker Compose файл:
|
||||
|
||||
```bash
|
||||
# 1. Клонируйте репозиторий
|
||||
git clone https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
|
||||
# 2. Настройте окружение
|
||||
cp env.example .env
|
||||
# Отредактируйте .env файл с вашими настройками
|
||||
|
||||
# 3. Запустите локально (Windows PowerShell)
|
||||
.\start-local.ps1
|
||||
|
||||
# Или вручную
|
||||
docker-compose -f docker-compose.local.yml up --build
|
||||
```
|
||||
|
||||
### 📁 Файлы для локальной разработки
|
||||
|
||||
- `docker-compose.local.yml` - Docker Compose для локальной разработки
|
||||
- `env.example` - Пример конфигурации
|
||||
- `start-local.ps1` - Скрипт быстрого запуска (Windows)
|
||||
- `start-local.sh` - Скрипт быстрого запуска (Linux/macOS)
|
||||
- `stop-local.ps1` - Скрипт остановки (Windows)
|
||||
- `README_LOCAL.md` - Подробная документация по локальной разработке
|
||||
|
||||
### 🔧 Особенности локальной разработки
|
||||
|
||||
- **Локальная сборка** - образ собирается из исходного кода
|
||||
- **Горячая перезагрузка** - изменения в коде автоматически применяются
|
||||
- **Доступ к БД** - PostgreSQL доступен на localhost:5432
|
||||
- **Доступ к Redis** - Redis доступен на localhost:6379
|
||||
- **Логи в реальном времени** - все логи выводятся в консоль
|
||||
- **Отладка** - полный доступ к контейнерам для отладки
|
||||
|
||||
### 📊 Мониторинг разработки
|
||||
|
||||
```bash
|
||||
# Просмотр логов
|
||||
docker-compose -f docker-compose.local.yml logs -f bot
|
||||
|
||||
# Проверка статуса
|
||||
docker-compose -f docker-compose.local.yml ps
|
||||
|
||||
# Health check
|
||||
curl http://localhost:8081/health
|
||||
|
||||
# Остановка
|
||||
docker-compose -f docker-compose.local.yml down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker развертывание
|
||||
|
||||
### 📄 docker-compose.yml
|
||||
@@ -812,6 +982,7 @@ docker compose down -v --remove-orphans
|
||||
- **Telegram Stars**: Работает автоматически
|
||||
- **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook`
|
||||
- **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook`
|
||||
- **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24
|
||||
|
||||
|
||||
### 🛠️ Настройка Уведомлений в топик группы
|
||||
@@ -1024,7 +1195,7 @@ docker system prune
|
||||
|----------|-------------|---------|
|
||||
| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет |
|
||||
| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL |
|
||||
| **Webhook не работает** | Проверь порты 8081/8082 | Настрой прокси-сервер правильно |
|
||||
| **Webhook не работает** | Проверь порты 8081/8082/8084 | Настрой прокси-сервер правильно |
|
||||
| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` и ключ |
|
||||
| **Мониторинг не работает** | Админ панель → Мониторинг | Проверь `MAINTENANCE_AUTO_ENABLE` |
|
||||
| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах |
|
||||
@@ -1045,20 +1216,41 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
|
||||
# CryptoBot webhook endpoint
|
||||
handle /cryptobot-webhook* {
|
||||
reverse_proxy localhost:8081 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
location /cryptobot-webhook {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
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;
|
||||
}
|
||||
|
||||
# MulenPay webhook endpoint
|
||||
location /mulenpay-webhook {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
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;
|
||||
}
|
||||
|
||||
# PayPalych webhook endpoint
|
||||
location /pal24-webhook {
|
||||
proxy_pass http://127.0.0.1:8084;
|
||||
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;
|
||||
}
|
||||
|
||||
# Для YooKassa
|
||||
location /yookassa-webhook {
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
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;
|
||||
}
|
||||
|
||||
# Health check
|
||||
@@ -1074,21 +1266,48 @@ your-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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 🧪 Проверка PayPalych postback
|
||||
|
||||
```bash
|
||||
# Генерируем подпись: md5("100.00:test-order-1:${PAL24_SIGNATURE_TOKEN}")
|
||||
SIGNATURE=$(python - <<'PY'
|
||||
import hashlib, os
|
||||
token = os.environ.get('PAL24_SIGNATURE_TOKEN', 'test_token')
|
||||
payload = f"100.00:test-order-1:{token}".encode()
|
||||
print(hashlib.md5(payload).hexdigest().upper())
|
||||
PY
|
||||
)
|
||||
|
||||
curl -X POST https://your-domain.com/pal24-webhook \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"InvId": "test-order-1", "OutSum": "100.00", "Status": "SUCCESS", "SignatureValue": "'$SIGNATURE'"}'
|
||||
```
|
||||
|
||||
Ответ `{"status": "ok"}` подтверждает корректную обработку вебхука.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Использование
|
||||
|
||||
+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": []
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -15,8 +15,8 @@ from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, common
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
)
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
@@ -31,11 +31,16 @@ from app.handlers.admin import (
|
||||
statistics as admin_statistics,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
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,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -116,6 +121,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
promocode.register_handlers(dp)
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
server_status.register_handlers(dp)
|
||||
tickets.register_handlers(dp)
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
@@ -127,12 +134,17 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.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)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+398
-42
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import html
|
||||
from collections import defaultdict
|
||||
from datetime import time
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
@@ -13,10 +15,24 @@ class Settings(BaseSettings):
|
||||
BOT_TOKEN: str
|
||||
ADMIN_IDS: str = ""
|
||||
SUPPORT_USERNAME: str = "@support"
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
# SLA for support tickets
|
||||
SUPPORT_TICKET_SLA_ENABLED: bool = True
|
||||
SUPPORT_TICKET_SLA_MINUTES: int = 5
|
||||
SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: int = 60
|
||||
SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: int = 15
|
||||
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
|
||||
|
||||
ADMIN_REPORTS_ENABLED: bool = False
|
||||
ADMIN_REPORTS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_REPORTS_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_REPORTS_SEND_TIME: Optional[str] = None
|
||||
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
@@ -37,8 +53,8 @@ class Settings(BaseSettings):
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
REMNAWAVE_API_URL: str
|
||||
REMNAWAVE_API_KEY: str
|
||||
REMNAWAVE_API_URL: str = ""
|
||||
REMNAWAVE_API_KEY: str = ""
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
|
||||
REMNAWAVE_USERNAME: Optional[str] = None
|
||||
@@ -50,18 +66,29 @@ class Settings(BaseSettings):
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: str
|
||||
TRIAL_SQUAD_UUID: str = ""
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
ENABLE_NOTIFICATIONS: bool = True
|
||||
NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
|
||||
MONITORING_LOGS_RETENTION_DAYS: int = 30
|
||||
NOTIFICATION_CACHE_HOURS: int = 24
|
||||
MONITORING_LOGS_RETENTION_DAYS: int = 30
|
||||
NOTIFICATION_CACHE_HOURS: int = 24
|
||||
|
||||
SERVER_STATUS_MODE: str = "disabled"
|
||||
SERVER_STATUS_EXTERNAL_URL: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_URL: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_USERNAME: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_PASSWORD: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_VERIFY_SSL: bool = True
|
||||
SERVER_STATUS_REQUEST_TIMEOUT: int = 10
|
||||
SERVER_STATUS_ITEMS_PER_PAGE: int = 10
|
||||
|
||||
BASE_SUBSCRIPTION_PRICE: int = 50000
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
|
||||
@@ -72,7 +99,7 @@ class Settings(BaseSettings):
|
||||
PRICE_90_DAYS: int = 269000
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
|
||||
|
||||
PRICE_TRAFFIC_5GB: int = 2000
|
||||
PRICE_TRAFFIC_10GB: int = 3500
|
||||
PRICE_TRAFFIC_25GB: int = 7000
|
||||
@@ -84,9 +111,12 @@ class Settings(BaseSettings):
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 20000
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG: str = ""
|
||||
|
||||
|
||||
PRICE_PER_DEVICE: int = 5000
|
||||
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
|
||||
|
||||
TRAFFIC_SELECTION_MODE: str = "selectable"
|
||||
FIXED_TRAFFIC_LIMIT_GB: int = 100
|
||||
|
||||
@@ -153,8 +183,48 @@ class Settings(BaseSettings):
|
||||
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
|
||||
|
||||
MULENPAY_ENABLED: bool = False
|
||||
MULENPAY_API_KEY: Optional[str] = None
|
||||
MULENPAY_SECRET_KEY: Optional[str] = None
|
||||
MULENPAY_SHOP_ID: Optional[int] = None
|
||||
MULENPAY_BASE_URL: str = "https://mulenpay.ru/api"
|
||||
MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook"
|
||||
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
|
||||
MULENPAY_LANGUAGE: str = "ru"
|
||||
MULENPAY_VAT_CODE: int = 0
|
||||
MULENPAY_PAYMENT_SUBJECT: int = 4
|
||||
MULENPAY_PAYMENT_MODE: int = 4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
|
||||
PAL24_ENABLED: bool = False
|
||||
PAL24_API_TOKEN: Optional[str] = None
|
||||
PAL24_SHOP_ID: Optional[str] = None
|
||||
PAL24_SIGNATURE_TOKEN: Optional[str] = None
|
||||
PAL24_BASE_URL: str = "https://pal24.pro/api/v1/"
|
||||
PAL24_WEBHOOK_PATH: str = "/pal24-webhook"
|
||||
PAL24_WEBHOOK_PORT: int = 8084
|
||||
PAL24_PAYMENT_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PAL24_REQUEST_TIMEOUT: int = 30
|
||||
PAL24_SBP_BUTTON_TEXT: Optional[str] = None
|
||||
PAL24_CARD_BUTTON_TEXT: Optional[str] = None
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
MINIAPP_PURCHASE_URL: str = ""
|
||||
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
|
||||
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection"
|
||||
MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение"
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_WINDOWS: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_PC: Optional[str] = None
|
||||
HIDE_SUBSCRIPTION_LINK: bool = False
|
||||
ENABLE_LOGO_MODE: bool = True
|
||||
LOGO_FILE: str = "vpn_logo.png"
|
||||
@@ -163,6 +233,7 @@ class Settings(BaseSettings):
|
||||
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/bot.log"
|
||||
@@ -170,6 +241,19 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
|
||||
WEB_API_ENABLED: bool = False
|
||||
WEB_API_HOST: str = "0.0.0.0"
|
||||
WEB_API_PORT: int = 8080
|
||||
WEB_API_WORKERS: int = 1
|
||||
WEB_API_ALLOWED_ORIGINS: str = "*"
|
||||
WEB_API_DOCS_ENABLED: bool = False
|
||||
WEB_API_TITLE: str = "Remnawave Bot Admin API"
|
||||
WEB_API_VERSION: str = "1.0.0"
|
||||
WEB_API_DEFAULT_TOKEN: Optional[str] = None
|
||||
WEB_API_DEFAULT_TOKEN_NAME: str = "Bootstrap Token"
|
||||
WEB_API_TOKEN_HASH_ALGORITHM: str = "sha256"
|
||||
WEB_API_REQUEST_LOGGING: bool = True
|
||||
|
||||
APP_CONFIG_PATH: str = "app-config.json"
|
||||
ENABLE_DEEP_LINKS: bool = True
|
||||
@@ -189,6 +273,63 @@ class Settings(BaseSettings):
|
||||
BACKUP_SEND_ENABLED: bool = False
|
||||
BACKUP_SEND_CHAT_ID: Optional[str] = None
|
||||
BACKUP_SEND_TOPIC_ID: Optional[int] = None
|
||||
|
||||
@field_validator('SERVER_STATUS_MODE', mode='before')
|
||||
@classmethod
|
||||
def normalize_server_status_mode(cls, value: Optional[str]) -> str:
|
||||
if not value:
|
||||
return "disabled"
|
||||
|
||||
normalized = str(value).strip().lower()
|
||||
aliases = {
|
||||
"off": "disabled",
|
||||
"none": "disabled",
|
||||
"disabled": "disabled",
|
||||
"external": "external_link",
|
||||
"link": "external_link",
|
||||
"url": "external_link",
|
||||
"external_link": "external_link",
|
||||
"miniapp": "external_link_miniapp",
|
||||
"mini_app": "external_link_miniapp",
|
||||
"mini-app": "external_link_miniapp",
|
||||
"webapp": "external_link_miniapp",
|
||||
"web_app": "external_link_miniapp",
|
||||
"web-app": "external_link_miniapp",
|
||||
"external_link_miniapp": "external_link_miniapp",
|
||||
"xray": "xray",
|
||||
"xraychecker": "xray",
|
||||
"xray_metrics": "xray",
|
||||
"metrics": "xray",
|
||||
}
|
||||
|
||||
mode = aliases.get(normalized, normalized)
|
||||
if mode not in {"disabled", "external_link", "external_link_miniapp", "xray"}:
|
||||
raise ValueError(
|
||||
"SERVER_STATUS_MODE must be one of: disabled, external_link, external_link_miniapp, xray"
|
||||
)
|
||||
return mode
|
||||
|
||||
@field_validator('SERVER_STATUS_ITEMS_PER_PAGE', mode='before')
|
||||
@classmethod
|
||||
def ensure_positive_server_status_page_size(cls, value: Optional[int]) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return 10
|
||||
value_int = int(value)
|
||||
return max(1, value_int)
|
||||
except (TypeError, ValueError):
|
||||
return 10
|
||||
|
||||
@field_validator('SERVER_STATUS_REQUEST_TIMEOUT', mode='before')
|
||||
@classmethod
|
||||
def ensure_positive_server_status_timeout(cls, value: Optional[int]) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return 10
|
||||
value_int = int(value)
|
||||
return max(1, value_int)
|
||||
except (TypeError, ValueError):
|
||||
return 10
|
||||
|
||||
@field_validator('LOG_FILE', mode='before')
|
||||
@classmethod
|
||||
@@ -265,6 +406,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'"""
|
||||
@@ -319,10 +468,39 @@ class Settings(BaseSettings):
|
||||
return ["ru", "en"]
|
||||
except AttributeError:
|
||||
return ["ru", "en"]
|
||||
|
||||
|
||||
def is_language_selection_enabled(self) -> bool:
|
||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks // 100
|
||||
return f"{rubles} ₽"
|
||||
|
||||
def get_reports_chat_id(self) -> Optional[str]:
|
||||
if self.ADMIN_REPORTS_CHAT_ID:
|
||||
return self.ADMIN_REPORTS_CHAT_ID
|
||||
return self.ADMIN_NOTIFICATIONS_CHAT_ID
|
||||
|
||||
def get_reports_topic_id(self) -> Optional[int]:
|
||||
return self.ADMIN_REPORTS_TOPIC_ID or None
|
||||
|
||||
def get_reports_send_time(self) -> Optional[time]:
|
||||
value = self.ADMIN_REPORTS_SEND_TIME
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
hours_str, minutes_str = value.strip().split(":", 1)
|
||||
hours = int(hours_str)
|
||||
minutes = int(minutes_str)
|
||||
if not (0 <= hours <= 23 and 0 <= minutes <= 59):
|
||||
raise ValueError
|
||||
return time(hour=hours, minute=minutes)
|
||||
except (ValueError, AttributeError):
|
||||
logging.getLogger(__name__).warning(
|
||||
"Некорректное значение ADMIN_REPORTS_SEND_TIME: %s", value
|
||||
)
|
||||
return None
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
return kopeks / 100
|
||||
@@ -345,6 +523,34 @@ 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
|
||||
@@ -371,9 +577,24 @@ class Settings(BaseSettings):
|
||||
return "https://t.me/"
|
||||
|
||||
def is_cryptobot_enabled(self) -> bool:
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
self.CRYPTOBOT_API_TOKEN is not None)
|
||||
|
||||
|
||||
def is_mulenpay_enabled(self) -> bool:
|
||||
return (
|
||||
self.MULENPAY_ENABLED
|
||||
and self.MULENPAY_API_KEY is not None
|
||||
and self.MULENPAY_SECRET_KEY is not None
|
||||
and self.MULENPAY_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def is_pal24_enabled(self) -> bool:
|
||||
return (
|
||||
self.PAL24_ENABLED
|
||||
and self.PAL24_API_TOKEN is not None
|
||||
and self.PAL24_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def get_cryptobot_base_url(self) -> str:
|
||||
if self.CRYPTOBOT_TESTNET:
|
||||
return "https://testnet-pay.crypt.bot"
|
||||
@@ -391,6 +612,41 @@ class Settings(BaseSettings):
|
||||
def get_cryptobot_invoice_expires_seconds(self) -> int:
|
||||
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
|
||||
|
||||
def is_happ_cryptolink_mode(self) -> bool:
|
||||
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
|
||||
|
||||
def is_happ_download_button_enabled(self) -> bool:
|
||||
return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED
|
||||
|
||||
def should_hide_subscription_link(self) -> bool:
|
||||
"""Returns True when subscription links must be hidden from the interface."""
|
||||
|
||||
if self.is_happ_cryptolink_mode():
|
||||
return False
|
||||
return self.HIDE_SUBSCRIPTION_LINK
|
||||
|
||||
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
|
||||
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
|
||||
return template or None
|
||||
|
||||
def get_happ_download_link(self, platform: str) -> Optional[str]:
|
||||
platform_key = platform.lower()
|
||||
|
||||
if platform_key == "pc":
|
||||
platform_key = "windows"
|
||||
|
||||
links = {
|
||||
"ios": (self.HAPP_DOWNLOAD_LINK_IOS or "").strip(),
|
||||
"android": (self.HAPP_DOWNLOAD_LINK_ANDROID or "").strip(),
|
||||
"macos": (self.HAPP_DOWNLOAD_LINK_MACOS or "").strip(),
|
||||
"windows": (
|
||||
(self.HAPP_DOWNLOAD_LINK_WINDOWS or "").strip()
|
||||
or (self.HAPP_DOWNLOAD_LINK_PC or "").strip()
|
||||
),
|
||||
}
|
||||
link = links.get(platform_key)
|
||||
return link if link else None
|
||||
|
||||
def is_maintenance_mode(self) -> bool:
|
||||
return self.MAINTENANCE_MODE
|
||||
|
||||
@@ -399,7 +655,46 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_maintenance_check_interval(self) -> int:
|
||||
return self.MAINTENANCE_CHECK_INTERVAL
|
||||
|
||||
|
||||
def is_base_promo_group_period_discount_enabled(self) -> bool:
|
||||
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
|
||||
|
||||
def get_base_promo_group_period_discounts(self) -> Dict[int, int]:
|
||||
try:
|
||||
config_str = (self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS or "").strip()
|
||||
if not config_str:
|
||||
return {}
|
||||
|
||||
discounts: Dict[int, int] = {}
|
||||
for part in config_str.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
period_and_discount = part.split(':')
|
||||
if len(period_and_discount) != 2:
|
||||
continue
|
||||
|
||||
period_str, discount_str = period_and_discount
|
||||
try:
|
||||
period_days = int(period_str.strip())
|
||||
discount_percent = int(discount_str.strip())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
discounts[period_days] = max(0, min(100, discount_percent))
|
||||
|
||||
return discounts
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_base_promo_group_period_discount(self, period_days: Optional[int]) -> int:
|
||||
if not period_days or not self.is_base_promo_group_period_discount_enabled():
|
||||
return 0
|
||||
|
||||
discounts = self.get_base_promo_group_period_discounts()
|
||||
return discounts.get(period_days, 0)
|
||||
|
||||
def is_maintenance_auto_enable(self) -> bool:
|
||||
return self.MAINTENANCE_AUTO_ENABLE
|
||||
|
||||
@@ -604,13 +899,43 @@ class Settings(BaseSettings):
|
||||
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
|
||||
]
|
||||
|
||||
def get_traffic_price(self, gb: int) -> int:
|
||||
def get_traffic_price(self, gb: Optional[int]) -> int:
|
||||
packages = self.get_traffic_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
|
||||
for package in packages:
|
||||
if package["gb"] == gb and package["enabled"]:
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
if gb is None:
|
||||
gb = 0
|
||||
|
||||
for package in enabled_packages:
|
||||
if package["gb"] == gb:
|
||||
return package["price"]
|
||||
|
||||
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
|
||||
|
||||
if gb <= 0:
|
||||
return unlimited_package["price"] if unlimited_package else 0
|
||||
|
||||
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
|
||||
|
||||
if not finite_packages:
|
||||
return unlimited_package["price"] if unlimited_package else 0
|
||||
|
||||
max_package = max(finite_packages, key=lambda x: x["gb"])
|
||||
|
||||
if gb >= max_package["gb"]:
|
||||
return unlimited_package["price"] if unlimited_package else max_package["price"]
|
||||
|
||||
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
|
||||
|
||||
if suitable_packages:
|
||||
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
|
||||
return nearest_package["price"]
|
||||
|
||||
return unlimited_package["price"] if unlimited_package else 0
|
||||
|
||||
def _clean_support_contact(self) -> str:
|
||||
return (self.SUPPORT_USERNAME or "").strip()
|
||||
|
||||
@@ -668,33 +993,64 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_support_contact_display_html(self) -> str:
|
||||
return html.escape(self.get_support_contact_display())
|
||||
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
|
||||
|
||||
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
|
||||
if finite_packages:
|
||||
max_package = max(finite_packages, key=lambda x: x["gb"])
|
||||
|
||||
if gb > max_package["gb"]:
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
else:
|
||||
return max_package["price"]
|
||||
|
||||
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
|
||||
if suitable_packages:
|
||||
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
|
||||
return nearest_package["price"]
|
||||
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
|
||||
return 0
|
||||
|
||||
def get_server_status_mode(self) -> str:
|
||||
return self.SERVER_STATUS_MODE
|
||||
|
||||
def is_server_status_enabled(self) -> bool:
|
||||
return self.get_server_status_mode() != "disabled"
|
||||
|
||||
def get_server_status_external_url(self) -> Optional[str]:
|
||||
url = (self.SERVER_STATUS_EXTERNAL_URL or "").strip()
|
||||
return url or None
|
||||
|
||||
def get_server_status_metrics_url(self) -> Optional[str]:
|
||||
url = (self.SERVER_STATUS_METRICS_URL or "").strip()
|
||||
return url or None
|
||||
|
||||
def get_server_status_metrics_auth(self) -> Optional[tuple[str, str]]:
|
||||
username = (self.SERVER_STATUS_METRICS_USERNAME or "").strip()
|
||||
password_raw = self.SERVER_STATUS_METRICS_PASSWORD
|
||||
|
||||
if not username:
|
||||
return None
|
||||
|
||||
password = "" if password_raw is None else str(password_raw)
|
||||
return username, password
|
||||
|
||||
def get_server_status_items_per_page(self) -> int:
|
||||
return max(1, self.SERVER_STATUS_ITEMS_PER_PAGE)
|
||||
|
||||
def get_server_status_request_timeout(self) -> int:
|
||||
return max(1, self.SERVER_STATUS_REQUEST_TIMEOUT)
|
||||
|
||||
def is_web_api_enabled(self) -> bool:
|
||||
return bool(self.WEB_API_ENABLED)
|
||||
|
||||
def get_web_api_allowed_origins(self) -> list[str]:
|
||||
raw = (self.WEB_API_ALLOWED_ORIGINS or "").split(",")
|
||||
origins = [origin.strip() for origin in raw if origin.strip()]
|
||||
return origins or ["*"]
|
||||
|
||||
def get_web_api_docs_config(self) -> Dict[str, Optional[str]]:
|
||||
if self.WEB_API_DOCS_ENABLED:
|
||||
return {
|
||||
"docs_url": "/docs",
|
||||
"redoc_url": "/redoc",
|
||||
"openapi_url": "/openapi.json",
|
||||
}
|
||||
|
||||
return {"docs_url": None, "redoc_url": None, "openapi_url": None}
|
||||
|
||||
def get_support_system_mode(self) -> str:
|
||||
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
def is_support_tickets_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"tickets", "both"}
|
||||
|
||||
def is_support_contact_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"contact", "both"}
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
|
||||
@@ -9,6 +9,12 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +32,7 @@ async def create_campaign(
|
||||
subscription_traffic_gb: Optional[int] = None,
|
||||
subscription_device_limit: Optional[int] = None,
|
||||
subscription_squads: Optional[List[str]] = None,
|
||||
is_active: bool = True,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
@@ -37,7 +44,7 @@ async def create_campaign(
|
||||
subscription_device_limit=subscription_device_limit,
|
||||
subscription_squads=subscription_squads or [],
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
@@ -129,7 +136,10 @@ async def update_campaign(
|
||||
"is_active",
|
||||
}
|
||||
|
||||
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
|
||||
update_data = {}
|
||||
for key, value in kwargs.items():
|
||||
if key in allowed_fields and value is not None:
|
||||
update_data[key] = value
|
||||
|
||||
if not update_data:
|
||||
return campaign
|
||||
@@ -157,6 +167,19 @@ async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bo
|
||||
return True
|
||||
|
||||
|
||||
async def get_campaign_registration_by_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[AdvertisingCampaignRegistration]:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaignRegistration)
|
||||
.options(selectinload(AdvertisingCampaignRegistration.campaign))
|
||||
.where(AdvertisingCampaignRegistration.user_id == user_id)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_campaign_registration(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -197,6 +220,11 @@ async def get_campaign_statistics(
|
||||
db: AsyncSession,
|
||||
campaign_id: int,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
registrations_query = select(AdvertisingCampaignRegistration.user_id).where(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id
|
||||
)
|
||||
registrations_subquery = registrations_query.subquery()
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(AdvertisingCampaignRegistration.id),
|
||||
@@ -207,6 +235,8 @@ async def get_campaign_statistics(
|
||||
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
)
|
||||
count, total_balance, last_registration = result.one()
|
||||
count = count or 0
|
||||
total_balance = total_balance or 0
|
||||
|
||||
subscription_count_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
@@ -216,12 +246,215 @@ async def get_campaign_statistics(
|
||||
)
|
||||
)
|
||||
)
|
||||
subscription_bonuses_issued = subscription_count_result.scalar() or 0
|
||||
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
)
|
||||
deposits_total = deposits_result.scalar() or 0
|
||||
|
||||
trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
)
|
||||
)
|
||||
trial_users_count = trials_result.scalar() or 0
|
||||
|
||||
active_trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
active_trials_count = active_trials_result.scalar() or 0
|
||||
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
conversion_count = conversions_result.scalar() or 0
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.id.in_(select(registrations_subquery.c.user_id)),
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
paid_users_from_flag = paid_users_result.scalar() or 0
|
||||
|
||||
conversions_rows = await db.execute(
|
||||
select(
|
||||
SubscriptionConversion.user_id,
|
||||
SubscriptionConversion.first_payment_amount_kopeks,
|
||||
SubscriptionConversion.converted_at,
|
||||
)
|
||||
.where(
|
||||
SubscriptionConversion.user_id.in_(
|
||||
select(registrations_subquery.c.user_id)
|
||||
)
|
||||
)
|
||||
.order_by(SubscriptionConversion.converted_at)
|
||||
)
|
||||
conversion_entries = conversions_rows.all()
|
||||
|
||||
subscription_payments_rows = await db.execute(
|
||||
select(
|
||||
Transaction.user_id,
|
||||
Transaction.amount_kopeks,
|
||||
Transaction.created_at,
|
||||
)
|
||||
.where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
.order_by(Transaction.user_id, Transaction.created_at)
|
||||
)
|
||||
subscription_payments = subscription_payments_rows.all()
|
||||
|
||||
subscription_payments_total = 0
|
||||
paid_users_from_transactions = set()
|
||||
conversion_user_ids = set()
|
||||
first_payment_amount_by_user: Dict[int, int] = {}
|
||||
first_payment_time_by_user: Dict[int, Optional[datetime]] = {}
|
||||
|
||||
for user_id, amount_kopeks, converted_at in conversion_entries:
|
||||
conversion_user_ids.add(user_id)
|
||||
amount_value = int(amount_kopeks or 0)
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = converted_at
|
||||
|
||||
for user_id, amount_kopeks, created_at in subscription_payments:
|
||||
amount_value = int(amount_kopeks or 0)
|
||||
subscription_payments_total += amount_value
|
||||
paid_users_from_transactions.add(user_id)
|
||||
|
||||
if user_id not in first_payment_amount_by_user:
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
else:
|
||||
existing_time = first_payment_time_by_user.get(user_id)
|
||||
if existing_time is None and created_at is not None:
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
elif (
|
||||
existing_time is not None
|
||||
and created_at is not None
|
||||
and created_at < existing_time
|
||||
):
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
|
||||
total_revenue = deposits_total + subscription_payments_total
|
||||
|
||||
paid_user_ids = set(paid_users_from_transactions)
|
||||
paid_user_ids.update(conversion_user_ids)
|
||||
paid_users_count = max(len(paid_user_ids), paid_users_from_flag)
|
||||
|
||||
conversion_count = conversion_count or len(paid_user_ids)
|
||||
if conversion_count < len(paid_user_ids):
|
||||
conversion_count = len(paid_user_ids)
|
||||
|
||||
avg_first_payment = 0
|
||||
if first_payment_amount_by_user:
|
||||
avg_first_payment = int(
|
||||
sum(first_payment_amount_by_user.values())
|
||||
/ len(first_payment_amount_by_user)
|
||||
)
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
trial_conversion_rate = 0.0
|
||||
if trial_users_count:
|
||||
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
|
||||
|
||||
avg_revenue_per_user = 0
|
||||
if count:
|
||||
avg_revenue_per_user = int(total_revenue / count)
|
||||
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
)
|
||||
total_revenue = deposits_result.scalar() or 0
|
||||
|
||||
trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
)
|
||||
)
|
||||
trial_users_count = trials_result.scalar() or 0
|
||||
|
||||
active_trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
active_trials_count = active_trials_result.scalar() or 0
|
||||
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
conversion_count = conversions_result.scalar() or 0
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.id.in_(select(registrations_subquery.c.user_id)),
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
paid_users_count = paid_users_result.scalar() or 0
|
||||
|
||||
avg_first_payment_result = await db.execute(
|
||||
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
trial_conversion_rate = 0.0
|
||||
if trial_users_count:
|
||||
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
|
||||
|
||||
avg_revenue_per_user = 0
|
||||
if count:
|
||||
avg_revenue_per_user = int(total_revenue / count)
|
||||
|
||||
return {
|
||||
"registrations": count or 0,
|
||||
"balance_issued": total_balance or 0,
|
||||
"subscription_issued": subscription_count_result.scalar() or 0,
|
||||
"registrations": count,
|
||||
"balance_issued": total_balance,
|
||||
"subscription_issued": subscription_bonuses_issued,
|
||||
"last_registration": last_registration,
|
||||
"total_revenue_kopeks": total_revenue,
|
||||
"trial_users_count": trial_users_count,
|
||||
"active_trials_count": active_trials_count,
|
||||
"conversion_count": conversion_count,
|
||||
"paid_users_count": paid_users_count,
|
||||
"conversion_rate": conversion_rate,
|
||||
"trial_conversion_rate": trial_conversion_rate,
|
||||
"avg_revenue_per_user_kopeks": avg_revenue_per_user,
|
||||
"avg_first_payment_kopeks": avg_first_payment,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import DiscountOffer
|
||||
|
||||
|
||||
async def upsert_discount_offer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
subscription_id: Optional[int],
|
||||
notification_type: str,
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
) -> DiscountOffer:
|
||||
"""Create or refresh a discount offer for a user."""
|
||||
|
||||
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.notification_type == notification_type,
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
)
|
||||
offer = result.scalars().first()
|
||||
|
||||
if offer and offer.claimed_at is None:
|
||||
offer.discount_percent = discount_percent
|
||||
offer.bonus_amount_kopeks = bonus_amount_kopeks
|
||||
offer.expires_at = expires_at
|
||||
offer.subscription_id = subscription_id
|
||||
else:
|
||||
offer = DiscountOffer(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
notification_type=notification_type,
|
||||
discount_percent=discount_percent,
|
||||
bonus_amount_kopeks=bonus_amount_kopeks,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
return offer
|
||||
|
||||
|
||||
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(DiscountOffer.id == offer_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
|
||||
offer.claimed_at = datetime.utcnow()
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
return offer
|
||||
|
||||
|
||||
async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.expires_at < now,
|
||||
)
|
||||
)
|
||||
offers = result.scalars().all()
|
||||
if not offers:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for offer in offers:
|
||||
offer.is_active = False
|
||||
count += 1
|
||||
|
||||
await db.commit()
|
||||
return count
|
||||
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_mulenpay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
uuid: str,
|
||||
description: str,
|
||||
payment_url: Optional[str],
|
||||
mulen_payment_id: Optional[int],
|
||||
currency: str,
|
||||
status: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> MulenPayPayment:
|
||||
payment = MulenPayPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
uuid=uuid,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
mulen_payment_id=mulen_payment_id,
|
||||
currency=currency,
|
||||
status=status,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
payment.mulen_payment_id,
|
||||
uuid,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_local_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_uuid(
|
||||
db: AsyncSession, uuid: str
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_mulen_id(
|
||||
db: AsyncSession, mulen_payment_id: int
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(
|
||||
MulenPayPayment.mulen_payment_id == mulen_payment_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_mulenpay_payment_status(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: MulenPayPayment,
|
||||
status: str,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
callback_payload: Optional[dict] = None,
|
||||
mulen_payment_id: Optional[int] = None,
|
||||
) -> MulenPayPayment:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at:
|
||||
payment.paid_at = paid_at
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
||||
payment.mulen_payment_id = mulen_payment_id
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_mulenpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: MulenPayPayment,
|
||||
transaction_id: int,
|
||||
) -> MulenPayPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -50,3 +50,17 @@ async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notification_by_type(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
SentNotification.subscription_id == subscription_id,
|
||||
SentNotification.notification_type == notification_type,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""CRUD helpers for PayPalych (Pal24) payments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Pal24Payment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_pal24_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
bill_id: str,
|
||||
amount_kopeks: int,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
type_: str,
|
||||
currency: str,
|
||||
link_url: Optional[str],
|
||||
link_page_url: Optional[str],
|
||||
order_id: Optional[str] = None,
|
||||
ttl: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Payment:
|
||||
payment = Pal24Payment(
|
||||
user_id=user_id,
|
||||
bill_id=bill_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
type=type_,
|
||||
link_url=link_url,
|
||||
link_page_url=link_page_url,
|
||||
metadata_json=metadata or {},
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)",
|
||||
payment.id,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
status,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_pal24_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: Pal24Payment,
|
||||
*,
|
||||
status: str,
|
||||
is_active: Optional[bool] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
payment_id: Optional[str] = None,
|
||||
payment_status: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
balance_amount: Optional[str] = None,
|
||||
balance_currency: Optional[str] = None,
|
||||
payer_account: Optional[str] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Payment:
|
||||
update_values: Dict[str, Any] = {
|
||||
"status": status,
|
||||
}
|
||||
|
||||
if is_active is not None:
|
||||
update_values["is_active"] = is_active
|
||||
if is_paid is not None:
|
||||
update_values["is_paid"] = is_paid
|
||||
if payment_id is not None:
|
||||
update_values["payment_id"] = payment_id
|
||||
if payment_status is not None:
|
||||
update_values["payment_status"] = payment_status
|
||||
if payment_method is not None:
|
||||
update_values["payment_method"] = payment_method
|
||||
if balance_amount is not None:
|
||||
update_values["balance_amount"] = balance_amount
|
||||
if balance_currency is not None:
|
||||
update_values["balance_currency"] = balance_currency
|
||||
if payer_account is not None:
|
||||
update_values["payer_account"] = payer_account
|
||||
if callback_payload is not None:
|
||||
update_values["callback_payload"] = callback_payload
|
||||
|
||||
update_values["last_status"] = status
|
||||
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment.id)
|
||||
.values(**update_values)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Pal24 платеж %s: статус=%s, is_paid=%s",
|
||||
payment.bill_id,
|
||||
payment.status,
|
||||
payment.is_paid,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_pal24_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
payment: Pal24Payment,
|
||||
transaction_id: int,
|
||||
) -> Pal24Payment:
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment.id)
|
||||
.values(transaction_id=transaction_id)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
"Pal24 платеж %s привязан к транзакции %s",
|
||||
payment.bill_id,
|
||||
transaction_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User
|
||||
|
||||
|
||||
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
|
||||
if not period_discounts:
|
||||
return {}
|
||||
|
||||
normalized: Dict[int, int] = {}
|
||||
|
||||
for key, value in period_discounts.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized[period] = max(0, min(100, percent))
|
||||
|
||||
return normalized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Tuple[PromoGroup, int]]:
|
||||
query = (
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
|
||||
)
|
||||
|
||||
if offset:
|
||||
query = query.offset(offset)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
|
||||
return await db.get(PromoGroup, group_id)
|
||||
|
||||
|
||||
async def count_promo_groups(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(PromoGroup.id)))
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.is_default.is_(True))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def create_promo_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: bool = True,
|
||||
is_default: bool = False,
|
||||
) -> PromoGroup:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
|
||||
auto_assign_total_spent_kopeks = (
|
||||
max(0, auto_assign_total_spent_kopeks)
|
||||
if auto_assign_total_spent_kopeks is not None
|
||||
else None
|
||||
)
|
||||
|
||||
existing_default = await get_default_promo_group(db)
|
||||
should_be_default = existing_default is None or is_default
|
||||
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
period_discounts=normalized_period_discounts or None,
|
||||
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=bool(apply_discounts_to_addons),
|
||||
is_default=should_be_default,
|
||||
)
|
||||
|
||||
db.add(promo_group)
|
||||
await db.flush()
|
||||
|
||||
if should_be_default and existing_default and existing_default.id != promo_group.id:
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id != promo_group.id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(promo_group)
|
||||
|
||||
logger.info(
|
||||
"Создана промогруппа '%s' (default=%s) с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
|
||||
promo_group.name,
|
||||
promo_group.is_default,
|
||||
promo_group.server_discount_percent,
|
||||
promo_group.traffic_discount_percent,
|
||||
promo_group.device_discount_percent,
|
||||
normalized_period_discounts,
|
||||
(auto_assign_total_spent_kopeks or 0) / 100,
|
||||
"on" if promo_group.apply_discounts_to_addons else "off",
|
||||
)
|
||||
|
||||
return promo_group
|
||||
|
||||
|
||||
async def update_promo_group(
|
||||
db: AsyncSession,
|
||||
group: PromoGroup,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
server_discount_percent: Optional[int] = None,
|
||||
traffic_discount_percent: Optional[int] = None,
|
||||
device_discount_percent: Optional[int] = None,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: Optional[bool] = None,
|
||||
is_default: Optional[bool] = None,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
if server_discount_percent is not None:
|
||||
group.server_discount_percent = max(0, min(100, server_discount_percent))
|
||||
if traffic_discount_percent is not None:
|
||||
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
|
||||
if device_discount_percent is not None:
|
||||
group.device_discount_percent = max(0, min(100, device_discount_percent))
|
||||
if period_discounts is not None:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
group.period_discounts = normalized_period_discounts or None
|
||||
if auto_assign_total_spent_kopeks is not None:
|
||||
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
|
||||
if apply_discounts_to_addons is not None:
|
||||
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
|
||||
|
||||
if is_default is not None:
|
||||
if is_default:
|
||||
group.is_default = True
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id != group.id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
else:
|
||||
if group.is_default:
|
||||
group.is_default = False
|
||||
await db.flush()
|
||||
replacement = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.id != group.id)
|
||||
.order_by(PromoGroup.id)
|
||||
.limit(1)
|
||||
)
|
||||
new_default = replacement.scalars().first()
|
||||
if new_default:
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id == new_default.id)
|
||||
.values(is_default=True)
|
||||
)
|
||||
else:
|
||||
# Не допускаем состояния без базовой промогруппы
|
||||
group.is_default = True
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
logger.info(
|
||||
"Обновлена промогруппа '%s' (id=%s)",
|
||||
group.name,
|
||||
group.id,
|
||||
)
|
||||
return group
|
||||
|
||||
|
||||
async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
if group.is_default:
|
||||
logger.warning("Попытка удалить базовую промогруппу запрещена")
|
||||
return False
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
logger.error("Не найдена базовая промогруппа для reassignment")
|
||||
return False
|
||||
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
.values(promo_group_id=default_group.id)
|
||||
)
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
|
||||
group.name,
|
||||
group.id,
|
||||
default_group.name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def get_promo_group_members(
|
||||
db: AsyncSession,
|
||||
group_id: int,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.promo_group_id == group_id)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_promo_group_members(db: AsyncSession, group_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count(User.id)).where(User.promo_group_id == group_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
@@ -1,14 +1,33 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy import select, and_, func, update, delete, text
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import (
|
||||
select,
|
||||
and_,
|
||||
func,
|
||||
update,
|
||||
delete,
|
||||
text,
|
||||
or_,
|
||||
cast,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad, SubscriptionServer, Subscription
|
||||
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_default_promo_group_id(db: AsyncSession) -> Optional[int]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_server_squad(
|
||||
db: AsyncSession,
|
||||
squad_uuid: str,
|
||||
@@ -18,9 +37,30 @@ async def create_server_squad(
|
||||
price_kopeks: int = 0,
|
||||
description: str = None,
|
||||
max_users: int = None,
|
||||
is_available: bool = True
|
||||
is_available: bool = True,
|
||||
promo_group_ids: Optional[Iterable[int]] = None,
|
||||
) -> ServerSquad:
|
||||
|
||||
|
||||
normalized_group_ids: Sequence[int]
|
||||
if promo_group_ids is None:
|
||||
default_id = await _get_default_promo_group_id(db)
|
||||
normalized_group_ids = [default_id] if default_id is not None else []
|
||||
else:
|
||||
normalized_group_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
|
||||
|
||||
if not normalized_group_ids:
|
||||
raise ValueError("Server squad must be linked to at least one promo group")
|
||||
|
||||
promo_groups_result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(normalized_group_ids))
|
||||
)
|
||||
promo_groups = promo_groups_result.scalars().all()
|
||||
|
||||
if len(promo_groups) != len(normalized_group_ids):
|
||||
logger.warning(
|
||||
"Не все промогруппы найдены при создании сервера %s", display_name
|
||||
)
|
||||
|
||||
server_squad = ServerSquad(
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=display_name,
|
||||
@@ -29,9 +69,10 @@ async def create_server_squad(
|
||||
price_kopeks=price_kopeks,
|
||||
description=description,
|
||||
max_users=max_users,
|
||||
is_available=is_available
|
||||
is_available=is_available,
|
||||
allowed_promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
|
||||
db.add(server_squad)
|
||||
await db.commit()
|
||||
await db.refresh(server_squad)
|
||||
@@ -46,9 +87,11 @@ async def get_server_squad_by_uuid(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid)
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.squad_uuid == squad_uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().unique().one_or_none()
|
||||
|
||||
|
||||
async def get_server_squad_by_id(
|
||||
@@ -57,9 +100,11 @@ async def get_server_squad_by_id(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.id == server_id)
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().unique().one_or_none()
|
||||
|
||||
|
||||
async def get_all_server_squads(
|
||||
@@ -91,14 +136,59 @@ async def get_all_server_squads(
|
||||
return servers, total_count
|
||||
|
||||
|
||||
async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
async def get_available_server_squads(
|
||||
db: AsyncSession,
|
||||
promo_group_id: Optional[int] = None,
|
||||
) -> List[ServerSquad]:
|
||||
|
||||
query = (
|
||||
select(ServerSquad)
|
||||
.where(ServerSquad.is_available == True)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.is_available.is_(True))
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
if promo_group_id is not None:
|
||||
query = query.join(ServerSquad.allowed_promo_groups).where(
|
||||
PromoGroup.id == promo_group_id
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def update_server_squad_promo_groups(
|
||||
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
|
||||
) -> Optional[ServerSquad]:
|
||||
unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
|
||||
|
||||
if not unique_ids:
|
||||
raise ValueError("Нужно выбрать хотя бы одну промогруппу")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(unique_ids))
|
||||
)
|
||||
promo_groups = result.scalars().all()
|
||||
|
||||
if not promo_groups:
|
||||
raise ValueError("Не найдены промогруппы для обновления сервера")
|
||||
|
||||
server.allowed_promo_groups = promo_groups
|
||||
await db.commit()
|
||||
await db.refresh(server)
|
||||
|
||||
logger.info(
|
||||
"Обновлены промогруппы сервера %s (ID: %s): %s",
|
||||
server.display_name,
|
||||
server.id,
|
||||
", ".join(pg.name for pg in promo_groups),
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
async def update_server_squad(
|
||||
@@ -153,10 +243,10 @@ async def sync_with_remnawave(
|
||||
db: AsyncSession,
|
||||
remnawave_squads: List[dict]
|
||||
) -> Tuple[int, int, int]:
|
||||
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
disabled = 0
|
||||
removed = 0
|
||||
|
||||
existing_servers = {}
|
||||
result = await db.execute(select(ServerSquad))
|
||||
@@ -186,15 +276,121 @@ async def sync_with_remnawave(
|
||||
)
|
||||
created += 1
|
||||
|
||||
for uuid, server in existing_servers.items():
|
||||
if uuid not in remnawave_uuids and server.is_available:
|
||||
server.is_available = False
|
||||
disabled += 1
|
||||
|
||||
removed_servers = [
|
||||
server for uuid, server in existing_servers.items()
|
||||
if uuid not in remnawave_uuids
|
||||
]
|
||||
|
||||
if removed_servers:
|
||||
removed_ids = [server.id for server in removed_servers]
|
||||
removed_uuids = {server.squad_uuid for server in removed_servers}
|
||||
|
||||
subscription_ids_result = await db.execute(
|
||||
select(SubscriptionServer.subscription_id)
|
||||
.where(SubscriptionServer.server_squad_id.in_(removed_ids))
|
||||
)
|
||||
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
|
||||
|
||||
for server in removed_servers:
|
||||
logger.info(
|
||||
"🗑️ Удаляется сервер %s (UUID: %s)",
|
||||
server.display_name,
|
||||
server.squad_uuid,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
|
||||
)
|
||||
|
||||
subscriptions_to_update: dict[int, Subscription] = {}
|
||||
|
||||
if subscription_ids:
|
||||
subscriptions_result = await db.execute(
|
||||
select(Subscription).where(Subscription.id.in_(subscription_ids))
|
||||
)
|
||||
for subscription in subscriptions_result.scalars().unique().all():
|
||||
subscriptions_to_update[subscription.id] = subscription
|
||||
|
||||
for squad_uuid in removed_uuids:
|
||||
if not squad_uuid:
|
||||
continue
|
||||
|
||||
extra_result = await db.execute(
|
||||
select(Subscription).where(
|
||||
text("connected_squads::text LIKE :uuid_pattern")
|
||||
),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
|
||||
for subscription in extra_result.scalars().unique().all():
|
||||
subscriptions_to_update[subscription.id] = subscription
|
||||
|
||||
cleaned_subscriptions = 0
|
||||
|
||||
for subscription in subscriptions_to_update.values():
|
||||
current_squads = list(subscription.connected_squads or [])
|
||||
if not current_squads:
|
||||
continue
|
||||
|
||||
filtered_squads = [
|
||||
squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
|
||||
]
|
||||
|
||||
if len(filtered_squads) != len(current_squads):
|
||||
subscription.connected_squads = filtered_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
cleaned_subscriptions += 1
|
||||
|
||||
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
|
||||
removed = len(removed_servers)
|
||||
|
||||
if cleaned_subscriptions:
|
||||
logger.info(
|
||||
"🧹 Обновлены подписки после удаления серверов: %s",
|
||||
cleaned_subscriptions,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
|
||||
return created, updated, disabled
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
|
||||
return created, updated, removed
|
||||
|
||||
|
||||
async def get_server_connected_users(
|
||||
db: AsyncSession,
|
||||
server_id: int
|
||||
) -> List[User]:
|
||||
|
||||
server_uuid_result = await db.execute(
|
||||
select(ServerSquad.squad_uuid).where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_uuid = server_uuid_result.scalar_one_or_none()
|
||||
|
||||
connection_filters = [SubscriptionServer.id.isnot(None)]
|
||||
|
||||
if server_uuid:
|
||||
connection_filters.append(
|
||||
cast(Subscription.connected_squads, String).like(
|
||||
f'%"{server_uuid}"%'
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(Subscription, Subscription.user_id == User.id)
|
||||
.outerjoin(
|
||||
SubscriptionServer,
|
||||
and_(
|
||||
SubscriptionServer.subscription_id == Subscription.id,
|
||||
SubscriptionServer.server_squad_id == server_id,
|
||||
),
|
||||
)
|
||||
.where(or_(*connection_filters))
|
||||
.options(selectinload(User.subscription))
|
||||
.order_by(User.id)
|
||||
)
|
||||
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
@@ -6,8 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
SubscriptionServer,
|
||||
PromoGroup,
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
@@ -113,13 +116,33 @@ async def extend_subscription(
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
|
||||
|
||||
if subscription.is_trial:
|
||||
start_date = subscription.start_date or current_time
|
||||
total_duration = subscription.end_date - start_date
|
||||
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
|
||||
|
||||
if total_duration > max_trial_duration:
|
||||
subscription.is_trial = False
|
||||
logger.info(
|
||||
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
|
||||
", итоговая длительность: %s дней",
|
||||
subscription.id,
|
||||
total_duration.days,
|
||||
)
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await clear_notifications(db, subscription.id)
|
||||
@@ -495,52 +518,144 @@ async def get_servers_monthly_prices(
|
||||
prices.append(price)
|
||||
return prices
|
||||
|
||||
def _get_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int
|
||||
devices: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, dict]:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'base_price_original': base_price_original,
|
||||
'base_discount_percent': period_discount_percent,
|
||||
'base_discount_total': base_discount_total,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'traffic_discount_percent': traffic_discount_percent,
|
||||
'traffic_discount_total': total_traffic_discount,
|
||||
'total_traffic_price': total_traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'servers_discount_percent': servers_discount_percent,
|
||||
'servers_discount_total': total_servers_discount,
|
||||
'total_servers_price': total_servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'devices_discount_percent': devices_discount_percent,
|
||||
'devices_discount_total': total_devices_discount,
|
||||
'total_devices_price': total_devices_price,
|
||||
'months_in_period': months_in_period,
|
||||
'servers_individual_prices': [price * months_in_period for price in servers_prices]
|
||||
'servers_individual_prices': [
|
||||
(price - (price * servers_discount_percent // 100)) * months_in_period
|
||||
for price in servers_prices
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
message = (
|
||||
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
message = (
|
||||
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
message = (
|
||||
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
@@ -614,19 +729,33 @@ async def remove_subscription_servers(
|
||||
async def get_subscription_renewal_cost(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
period_days: int
|
||||
period_days: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
subscription = await db.get(Subscription, subscription_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
|
||||
if user is None:
|
||||
user = subscription.user
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
@@ -637,26 +766,74 @@ async def get_subscription_renewal_cost(
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
total_servers_cost = servers_price_per_month * months_in_period
|
||||
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_cost = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_cost = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_cost = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_cost = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_cost = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период: {base_price/100}₽")
|
||||
if total_servers_cost > 0:
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽")
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_traffic_cost > 0:
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽")
|
||||
message = (
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽"
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_cost > 0:
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽")
|
||||
message = (
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽"
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost
|
||||
@@ -671,27 +848,65 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None
|
||||
additional_server_ids: List[int] = None,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
|
||||
total_cost = 0
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_total_cost = traffic_price_per_month * months_to_pay
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_cost = discounted_traffic_per_month * months_to_pay
|
||||
total_cost += traffic_total_cost
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽")
|
||||
|
||||
message = (
|
||||
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_cost = devices_price_per_month * months_to_pay
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_cost = discounted_devices_per_month * months_to_pay
|
||||
total_cost += devices_total_cost
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽")
|
||||
|
||||
message = (
|
||||
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in additional_server_ids:
|
||||
@@ -702,9 +917,24 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
server_total_cost = server_price_per_month * months_to_pay
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_cost = discounted_server_per_month * months_to_pay
|
||||
total_cost += server_total_cost
|
||||
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽")
|
||||
message = (
|
||||
f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽")
|
||||
return total_cost
|
||||
@@ -755,7 +985,8 @@ async def create_subscription(
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = ""
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = ""
|
||||
) -> Subscription:
|
||||
|
||||
if end_date is None:
|
||||
@@ -774,7 +1005,8 @@ async def create_subscription(
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
|
||||
async def upsert_system_setting(
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
value: Optional[str],
|
||||
description: Optional[str] = None,
|
||||
) -> SystemSetting:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = SystemSetting(key=key, value=value, description=description)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.value = value
|
||||
if description is not None:
|
||||
setting.description = description
|
||||
|
||||
await db.flush()
|
||||
return setting
|
||||
|
||||
|
||||
async def delete_system_setting(db: AsyncSession, key: str) -> None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting is not None:
|
||||
await db.delete(setting)
|
||||
await db.flush()
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_, or_, update, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
|
||||
|
||||
|
||||
class TicketCRUD:
|
||||
"""CRUD операции для работы с тикетами"""
|
||||
|
||||
@staticmethod
|
||||
async def create_ticket(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message_text: str,
|
||||
priority: str = "normal",
|
||||
*,
|
||||
media_type: Optional[str] = None,
|
||||
media_file_id: Optional[str] = None,
|
||||
media_caption: Optional[str] = None,
|
||||
) -> Ticket:
|
||||
"""Создать новый тикет с первым сообщением"""
|
||||
ticket = Ticket(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
status=TicketStatus.OPEN.value,
|
||||
priority=priority
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush() # Получаем ID тикета
|
||||
|
||||
# Создаем первое сообщение
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=False,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_by_id(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
load_messages: bool = True,
|
||||
load_user: bool = False
|
||||
) -> Optional[Ticket]:
|
||||
"""Получить тикет по ID"""
|
||||
query = select(Ticket).where(Ticket.id == ticket_id)
|
||||
|
||||
if load_user:
|
||||
query = query.options(selectinload(Ticket.user))
|
||||
|
||||
if load_messages:
|
||||
query = query.options(selectinload(Ticket.messages))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tickets(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить тикеты пользователя"""
|
||||
query = select(Ticket).where(Ticket.user_id == user_id)
|
||||
|
||||
if status:
|
||||
query = query.where(Ticket.status == status)
|
||||
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_user_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
statuses: List[str]
|
||||
) -> int:
|
||||
"""Подсчитать количество тикетов пользователя по списку статусов"""
|
||||
query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
statuses: List[str],
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить тикеты пользователя по списку статусов с пагинацией"""
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.user_id == user_id)
|
||||
.order_by(desc(Ticket.updated_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def user_has_active_ticket(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
"""Проверить, есть ли у пользователя активный (не закрытый) тикет"""
|
||||
query = (
|
||||
select(Ticket.id)
|
||||
.where(
|
||||
Ticket.user_id == user_id,
|
||||
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@staticmethod
|
||||
async def is_user_globally_blocked(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[datetime]:
|
||||
"""Проверить, заблокирован ли пользователь для создания/ответов по любому тикету.
|
||||
Возвращает дату окончания блокировки, если активна, или None.
|
||||
"""
|
||||
query = select(Ticket).where(
|
||||
Ticket.user_id == user_id,
|
||||
or_(Ticket.user_reply_block_permanent == True, Ticket.user_reply_block_until.isnot(None))
|
||||
).order_by(desc(Ticket.updated_at)).limit(10)
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
if not tickets:
|
||||
return None
|
||||
from datetime import datetime
|
||||
# Если есть вечная блокировка в любом тикете — блок активен без срока
|
||||
for t in tickets:
|
||||
if t.user_reply_block_permanent:
|
||||
return datetime.max
|
||||
# Иначе ищем максимальный срок блокировки, если он в будущем
|
||||
future_until = [t.user_reply_block_until for t in tickets if t.user_reply_block_until]
|
||||
if not future_until:
|
||||
return None
|
||||
max_until = max(future_until)
|
||||
return max_until if max_until > datetime.utcnow() else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_tickets(
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить все тикеты (для админов)"""
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
|
||||
conditions = []
|
||||
if status:
|
||||
conditions.append(Ticket.status == status)
|
||||
if priority:
|
||||
conditions.append(Ticket.priority == priority)
|
||||
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
statuses: List[str],
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_tickets(
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None
|
||||
) -> int:
|
||||
query = select(func.count()).select_from(Ticket)
|
||||
if status:
|
||||
query = query.where(Ticket.status == status)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def count_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
statuses: List[str]
|
||||
) -> int:
|
||||
query = select(func.count()).select_from(Ticket)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def update_ticket_status(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
status: str,
|
||||
closed_at: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""Обновить статус тикета"""
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
return False
|
||||
|
||||
ticket.status = status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
if status == TicketStatus.CLOSED.value and closed_at:
|
||||
ticket.closed_at = closed_at
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def set_user_reply_block(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
permanent: bool,
|
||||
until: Optional[datetime]
|
||||
) -> bool:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
return False
|
||||
ticket.user_reply_block_permanent = bool(permanent)
|
||||
ticket.user_reply_block_until = until
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def close_ticket(
|
||||
db: AsyncSession,
|
||||
ticket_id: int
|
||||
) -> bool:
|
||||
"""Закрыть тикет"""
|
||||
return await TicketCRUD.update_ticket_status(
|
||||
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def add_support_audit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
actor_user_id: Optional[int],
|
||||
actor_telegram_id: int,
|
||||
is_moderator: bool,
|
||||
action: str,
|
||||
ticket_id: Optional[int] = None,
|
||||
target_user_id: Optional[int] = None,
|
||||
details: Optional[dict] = None,
|
||||
) -> None:
|
||||
try:
|
||||
log = SupportAuditLog(
|
||||
actor_user_id=actor_user_id,
|
||||
actor_telegram_id=actor_telegram_id,
|
||||
is_moderator=bool(is_moderator),
|
||||
action=action,
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=target_user_id,
|
||||
details=details or {},
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
# не мешаем основной логике
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def list_support_audit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[SupportAuditLog]:
|
||||
from sqlalchemy import select, desc
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_support_audit(db: AsyncSession) -> int:
|
||||
from sqlalchemy import select, func
|
||||
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def get_open_tickets_count(db: AsyncSession) -> int:
|
||||
"""Получить количество открытых тикетов"""
|
||||
query = select(Ticket).where(Ticket.status.in_([
|
||||
TicketStatus.OPEN.value,
|
||||
TicketStatus.ANSWERED.value
|
||||
]))
|
||||
result = await db.execute(query)
|
||||
return len(result.scalars().all())
|
||||
|
||||
|
||||
class TicketMessageCRUD:
|
||||
"""CRUD операции для работы с сообщениями тикетов"""
|
||||
|
||||
@staticmethod
|
||||
async def add_message(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
user_id: int,
|
||||
message_text: str,
|
||||
is_from_admin: bool = False,
|
||||
media_type: Optional[str] = None,
|
||||
media_file_id: Optional[str] = None,
|
||||
media_caption: Optional[str] = None
|
||||
) -> TicketMessage:
|
||||
"""Добавить сообщение в тикет"""
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket_id,
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=is_from_admin,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
|
||||
# Обновляем статус тикета
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if ticket:
|
||||
# Если тикет закрыт, запрещаем изменение статуса при сообщении пользователя
|
||||
if not is_from_admin and ticket.status == TicketStatus.CLOSED.value:
|
||||
return message
|
||||
if is_from_admin:
|
||||
# Админ ответил - тикет отвечен
|
||||
ticket.status = TicketStatus.ANSWERED.value
|
||||
else:
|
||||
# Пользователь ответил - тикет открыт
|
||||
ticket.status = TicketStatus.OPEN.value
|
||||
# Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения
|
||||
try:
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
# если колонка существует в модели
|
||||
if hasattr(ticket, 'last_sla_reminder_at'):
|
||||
ticket.last_sla_reminder_at = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_messages(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[TicketMessage]:
|
||||
"""Получить сообщения тикета"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(TicketMessage.created_at)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_last_message(
|
||||
db: AsyncSession,
|
||||
ticket_id: int
|
||||
) -> Optional[TicketMessage]:
|
||||
"""Получить последнее сообщение в тикете"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(desc(TicketMessage.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -37,6 +37,20 @@ async def create_transaction(
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
maybe_assign_promo_group_by_total_spent,
|
||||
)
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
@@ -98,15 +112,42 @@ async def get_user_transactions_count(
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
|
||||
|
||||
|
||||
transaction.is_completed = True
|
||||
transaction.completed_at = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
|
||||
logger.info(f"✅ Транзакция {transaction.id} завершена")
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
maybe_assign_promo_group_by_total_spent,
|
||||
)
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
|
||||
transaction.user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
|
||||
+207
-24
@@ -2,13 +2,23 @@ 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
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction
|
||||
from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
PaymentMethod,
|
||||
TransactionType,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +32,10 @@ def generate_referral_code() -> str:
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -36,7 +49,10 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -49,7 +65,9 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User).where(User.referral_code == referral_code)
|
||||
select(User)
|
||||
.options(selectinload(User.promo_group))
|
||||
.where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -79,28 +97,47 @@ async def create_user(
|
||||
) -> User:
|
||||
|
||||
if not referral_code:
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
default_group = PromoGroup(
|
||||
name="Базовый юзер",
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
|
||||
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=first_name,
|
||||
last_name=last_name,
|
||||
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
|
||||
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
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -110,7 +147,10 @@ async def update_user(
|
||||
**kwargs
|
||||
) -> User:
|
||||
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
for field, value in kwargs.items():
|
||||
if field in ("first_name", "last_name"):
|
||||
value = sanitize_telegram_name(value)
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -179,10 +219,12 @@ async def add_user_balance_by_id(
|
||||
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
create_transaction: bool = False,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
) -> bool:
|
||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
@@ -201,7 +243,21 @@ async def subtract_user_balance(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.WITHDRAWAL,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
logger.error(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}")
|
||||
return True
|
||||
|
||||
@@ -217,7 +273,11 @@ async def get_users_list(
|
||||
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))
|
||||
@@ -237,10 +297,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())
|
||||
|
||||
@@ -278,10 +399,69 @@ 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)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
@@ -293,7 +473,10 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity < threshold_date,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""CRUD операции для токенов административного веб-API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WebApiToken
|
||||
|
||||
|
||||
async def list_tokens(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> List[WebApiToken]:
|
||||
query = select(WebApiToken)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(WebApiToken.is_active.is_(True))
|
||||
|
||||
query = query.order_by(WebApiToken.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
|
||||
return await db.get(WebApiToken, token_id)
|
||||
|
||||
|
||||
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
|
||||
query = select(WebApiToken).where(
|
||||
WebApiToken.token_hash == token_hash
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_token(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> WebApiToken:
|
||||
token = WebApiToken(
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=token_prefix,
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(token)
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def update_token(
|
||||
db: AsyncSession,
|
||||
token: WebApiToken,
|
||||
**kwargs,
|
||||
) -> WebApiToken:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(token, key):
|
||||
setattr(token, key, value)
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def set_tokens_active_status(
|
||||
db: AsyncSession,
|
||||
token_ids: Iterable[int],
|
||||
*,
|
||||
is_active: bool,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
update(WebApiToken)
|
||||
.where(WebApiToken.id.in_(list(token_ids)))
|
||||
.values(is_active=is_active, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
|
||||
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
|
||||
await db.delete(token)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"list_tokens",
|
||||
"get_token_by_id",
|
||||
"get_token_by_hash",
|
||||
"create_token",
|
||||
"update_token",
|
||||
"set_tokens_active_status",
|
||||
"delete_token",
|
||||
]
|
||||
@@ -10,7 +10,7 @@ from app.database.models import Base
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
settings.get_database_url(),
|
||||
poolclass=NullPool,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
@@ -48,4 +48,4 @@ async def init_db():
|
||||
|
||||
async def close_db():
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
|
||||
+413
-13
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -14,6 +14,8 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
Table,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
@@ -23,6 +25,24 @@ from sqlalchemy.sql import func
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
server_squad_promo_groups = Table(
|
||||
"server_squad_promo_groups",
|
||||
Base.metadata,
|
||||
Column(
|
||||
"server_squad_id",
|
||||
Integer,
|
||||
ForeignKey("server_squads.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
"promo_group_id",
|
||||
Integer,
|
||||
ForeignKey("promo_groups.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UserStatus(Enum):
|
||||
ACTIVE = "active"
|
||||
BLOCKED = "blocked"
|
||||
@@ -53,9 +73,11 @@ class PromoCodeType(Enum):
|
||||
class PaymentMethod(Enum):
|
||||
TELEGRAM_STARS = "telegram_stars"
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
YOOKASSA = "yookassa"
|
||||
CRYPTOBOT = "cryptobot"
|
||||
MANUAL = "manual"
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
MANUAL = "manual"
|
||||
|
||||
class YooKassaPayment(Base):
|
||||
__tablename__ = "yookassa_payments"
|
||||
@@ -107,7 +129,7 @@ class YooKassaPayment(Base):
|
||||
|
||||
class CryptoBotPayment(Base):
|
||||
__tablename__ = "cryptobot_payments"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
@@ -155,9 +177,193 @@ class CryptoBotPayment(Base):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class MulenPayPayment(Base):
|
||||
__tablename__ = "mulenpay_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
mulen_payment_id = Column(Integer, nullable=True, index=True)
|
||||
uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
status = Column(String(50), nullable=False, default="created")
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
payment_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="mulenpay_payments")
|
||||
transaction = relationship("Transaction", backref="mulenpay_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<MulenPayPayment(id={0}, mulen_id={1}, amount={2}₽, status={3})>".format(
|
||||
self.id,
|
||||
self.mulen_payment_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Pal24Payment(Base):
|
||||
__tablename__ = "pal24_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
bill_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
order_id = Column(String(255), nullable=True, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
type = Column(String(20), nullable=False, default="normal")
|
||||
|
||||
status = Column(String(50), nullable=False, default="NEW")
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
last_status = Column(String(50), nullable=True)
|
||||
last_status_checked_at = Column(DateTime, nullable=True)
|
||||
|
||||
link_url = Column(Text, nullable=True)
|
||||
link_page_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
payment_id = Column(String(255), nullable=True, index=True)
|
||||
payment_status = Column(String(50), nullable=True)
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
balance_amount = Column(String(50), nullable=True)
|
||||
balance_currency = Column(String(10), nullable=True)
|
||||
payer_account = Column(String(255), nullable=True)
|
||||
|
||||
ttl = Column(Integer, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="pal24_payments")
|
||||
transaction = relationship("Transaction", backref="pal24_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status in {"NEW", "PROCESS"}
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<Pal24Payment(id={0}, bill_id={1}, amount={2}₽, status={3})>".format(
|
||||
self.id,
|
||||
self.bill_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), unique=True, nullable=False)
|
||||
server_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
period_discounts = Column(JSON, nullable=True, default=dict)
|
||||
auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None)
|
||||
apply_discounts_to_addons = Column(Boolean, nullable=False, default=True)
|
||||
is_default = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
users = relationship("User", back_populates="promo_group")
|
||||
server_squads = relationship(
|
||||
"ServerSquad",
|
||||
secondary=server_squad_promo_groups,
|
||||
back_populates="allowed_promo_groups",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def _get_period_discounts_map(self) -> Dict[int, int]:
|
||||
raw_discounts = self.period_discounts or {}
|
||||
|
||||
if isinstance(raw_discounts, dict):
|
||||
items = raw_discounts.items()
|
||||
else:
|
||||
items = []
|
||||
|
||||
normalized: Dict[int, int] = {}
|
||||
|
||||
for key, value in items:
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized[period] = max(0, min(100, percent))
|
||||
|
||||
return normalized
|
||||
|
||||
def _get_period_discount(self, period_days: Optional[int]) -> int:
|
||||
if not period_days:
|
||||
return 0
|
||||
|
||||
discounts = self._get_period_discounts_map()
|
||||
|
||||
if period_days in discounts:
|
||||
return discounts[period_days]
|
||||
|
||||
if self.is_default:
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_base_promo_group_period_discount_enabled():
|
||||
config_discounts = settings.get_base_promo_group_period_discounts()
|
||||
return config_discounts.get(period_days, 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
return 0
|
||||
|
||||
def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
if category == "period":
|
||||
return max(0, min(100, self._get_period_discount(period_days)))
|
||||
|
||||
mapping = {
|
||||
"servers": self.server_discount_percent,
|
||||
"traffic": self.traffic_discount_percent,
|
||||
"devices": self.device_discount_percent,
|
||||
}
|
||||
percent = mapping.get(category, 0)
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
|
||||
username = Column(String(255), nullable=True)
|
||||
@@ -179,12 +385,17 @@ class User(Base):
|
||||
subscription = relationship("Subscription", back_populates="user", uselist=False)
|
||||
transactions = relationship("Transaction", back_populates="user")
|
||||
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
|
||||
discount_offers = relationship("DiscountOffer", back_populates="user")
|
||||
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)
|
||||
last_remnawave_sync = Column(DateTime, nullable=True)
|
||||
trojan_password = Column(String(255), nullable=True)
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
ss_password = Column(String(255), nullable=True)
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -194,6 +405,11 @@ class User(Base):
|
||||
def full_name(self) -> str:
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
if not self.promo_group:
|
||||
return 0
|
||||
return self.promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
@@ -221,7 +437,8 @@ class Subscription(Base):
|
||||
traffic_used_gb = Column(Float, default=0.0)
|
||||
|
||||
subscription_url = Column(String, nullable=True)
|
||||
|
||||
subscription_crypto_link = Column(String, nullable=True)
|
||||
|
||||
device_limit = Column(Integer, default=1)
|
||||
|
||||
connected_squads = Column(JSON, default=list)
|
||||
@@ -233,8 +450,9 @@ class Subscription(Base):
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
user = relationship("User", back_populates="subscription")
|
||||
discount_offers = relationship("DiscountOffer", back_populates="subscription")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
@@ -353,8 +571,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:
|
||||
@@ -578,6 +795,28 @@ class SentNotification(Base):
|
||||
user = relationship("User", backref="sent_notifications")
|
||||
subscription = relationship("Subscription", backref="sent_notifications")
|
||||
|
||||
|
||||
class DiscountOffer(Base):
|
||||
__tablename__ = "discount_offers"
|
||||
__table_args__ = (
|
||||
Index("ix_discount_offers_user_type", "user_id", "notification_type"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True)
|
||||
notification_type = Column(String(50), nullable=False)
|
||||
discount_percent = Column(Integer, nullable=False, default=0)
|
||||
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
claimed_at = Column(DateTime, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
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")
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
@@ -600,9 +839,9 @@ class BroadcastHistory(Base):
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
|
||||
squad_uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
display_name = Column(String(255), nullable=False)
|
||||
@@ -620,10 +859,17 @@ class ServerSquad(Base):
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
max_users = Column(Integer, nullable=True)
|
||||
current_users = Column(Integer, default=0)
|
||||
|
||||
current_users = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
allowed_promo_groups = relationship(
|
||||
"PromoGroup",
|
||||
secondary=server_squad_promo_groups,
|
||||
back_populates="server_squads",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
@property
|
||||
def price_rubles(self) -> float:
|
||||
@@ -659,6 +905,23 @@ class SubscriptionServer(Base):
|
||||
subscription = relationship("Subscription", backref="subscription_servers")
|
||||
server_squad = relationship("ServerSquad", backref="subscription_servers")
|
||||
|
||||
|
||||
class SupportAuditLog(Base):
|
||||
__tablename__ = "support_audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
actor_telegram_id = Column(BigInteger, nullable=False)
|
||||
is_moderator = Column(Boolean, default=False)
|
||||
action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user
|
||||
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
|
||||
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
details = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
actor = relationship("User", foreign_keys=[actor_user_id])
|
||||
ticket = relationship("Ticket", foreign_keys=[ticket_id])
|
||||
|
||||
class UserMessage(Base):
|
||||
__tablename__ = "user_messages"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
@@ -741,3 +1004,140 @@ class AdvertisingCampaignRegistration(Base):
|
||||
@property
|
||||
def balance_bonus_rubles(self) -> float:
|
||||
return (self.balance_bonus_kopeks or 0) / 100
|
||||
|
||||
|
||||
class TicketStatus(Enum):
|
||||
OPEN = "open"
|
||||
ANSWERED = "answered"
|
||||
CLOSED = "closed"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
class Ticket(Base):
|
||||
__tablename__ = "tickets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
title = Column(String(255), nullable=False)
|
||||
status = Column(String(20), default=TicketStatus.OPEN.value, nullable=False)
|
||||
priority = Column(String(20), default="normal", nullable=False) # low, normal, high, urgent
|
||||
# Блокировка ответов пользователя в этом тикете
|
||||
user_reply_block_permanent = Column(Boolean, default=False, nullable=False)
|
||||
user_reply_block_until = Column(DateTime, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
# SLA reminders
|
||||
last_sla_reminder_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Связи
|
||||
user = relationship("User", backref="tickets")
|
||||
messages = relationship("TicketMessage", back_populates="ticket", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.status == TicketStatus.OPEN.value
|
||||
|
||||
@property
|
||||
def is_answered(self) -> bool:
|
||||
return self.status == TicketStatus.ANSWERED.value
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self.status == TicketStatus.CLOSED.value
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == TicketStatus.PENDING.value
|
||||
|
||||
@property
|
||||
def is_user_reply_blocked(self) -> bool:
|
||||
if self.user_reply_block_permanent:
|
||||
return True
|
||||
if self.user_reply_block_until:
|
||||
try:
|
||||
from datetime import datetime
|
||||
return self.user_reply_block_until > datetime.utcnow()
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
status_emojis = {
|
||||
TicketStatus.OPEN.value: "🔴",
|
||||
TicketStatus.ANSWERED.value: "🟡",
|
||||
TicketStatus.CLOSED.value: "🟢",
|
||||
TicketStatus.PENDING.value: "⏳"
|
||||
}
|
||||
return status_emojis.get(self.status, "❓")
|
||||
|
||||
@property
|
||||
def priority_emoji(self) -> str:
|
||||
priority_emojis = {
|
||||
"low": "🟢",
|
||||
"normal": "🟡",
|
||||
"high": "🟠",
|
||||
"urgent": "🔴"
|
||||
}
|
||||
return priority_emojis.get(self.priority, "🟡")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Ticket(id={self.id}, user_id={self.user_id}, status={self.status}, title='{self.title[:30]}...')>"
|
||||
|
||||
|
||||
class TicketMessage(Base):
|
||||
__tablename__ = "ticket_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
message_text = Column(Text, nullable=False)
|
||||
is_from_admin = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Для медиа файлов
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc.
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
media_caption = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# Связи
|
||||
ticket = relationship("Ticket", back_populates="messages")
|
||||
user = relationship("User")
|
||||
|
||||
@property
|
||||
def is_user_message(self) -> bool:
|
||||
return not self.is_from_admin
|
||||
|
||||
@property
|
||||
def is_admin_message(self) -> bool:
|
||||
return self.is_from_admin
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
||||
|
||||
|
||||
class WebApiToken(Base):
|
||||
__tablename__ = "web_api_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
token_hash = Column(String(128), nullable=False, unique=True, index=True)
|
||||
token_prefix = Column(String(32), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
last_used_ip = Column(String(64), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(String(255), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "revoked"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
+1573
-11
File diff suppressed because it is too large
Load Diff
Vendored
+216
@@ -0,0 +1,216 @@
|
||||
"""Async client for PayPalych (Pal24) API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pal24APIError(Exception):
|
||||
"""Base error for Pal24 API operations."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Pal24Response:
|
||||
"""Wrapper for Pal24 API responses."""
|
||||
|
||||
success: bool
|
||||
data: Dict[str, Any]
|
||||
status: int
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Dict[str, Any], status: int) -> "Pal24Response":
|
||||
success = bool(payload.get("success", status < 400))
|
||||
return cls(success=success, data=payload, status=status)
|
||||
|
||||
def raise_for_status(self, endpoint: str) -> None:
|
||||
if not self.success:
|
||||
detail = self.data.get("message") or self.data.get("error")
|
||||
raise Pal24APIError(
|
||||
f"Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}"
|
||||
)
|
||||
|
||||
|
||||
class Pal24Client:
|
||||
"""Async client implementing PayPalych API methods."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_token: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
self.api_token = api_token or settings.PAL24_API_TOKEN
|
||||
self.base_url = (base_url or settings.PAL24_BASE_URL or "").rstrip("/") + "/"
|
||||
self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT
|
||||
|
||||
if not self.api_token:
|
||||
logger.warning("Pal24Client initialized without API token")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.api_token and self.base_url)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_payload: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Response:
|
||||
if not self.is_configured:
|
||||
raise Pal24APIError("Pal24 client is not configured")
|
||||
|
||||
url = f"{self.base_url}{endpoint.lstrip('/')}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_payload,
|
||||
params=params,
|
||||
) as response:
|
||||
status = response.status
|
||||
try:
|
||||
payload = await response.json(content_type=None)
|
||||
except aiohttp.ContentTypeError:
|
||||
text_body = await response.text()
|
||||
logger.error(
|
||||
"Pal24 API returned non-JSON response for %s: %s",
|
||||
endpoint,
|
||||
text_body,
|
||||
)
|
||||
raise Pal24APIError(
|
||||
f"Pal24 API returned non-JSON response: {text_body}"
|
||||
) from None
|
||||
|
||||
result = Pal24Response.from_payload(payload, status)
|
||||
if status >= 400 or not result.success:
|
||||
logger.error(
|
||||
"Pal24 API error %s %s: %s",
|
||||
status,
|
||||
endpoint,
|
||||
payload,
|
||||
)
|
||||
result.raise_for_status(endpoint)
|
||||
|
||||
return result
|
||||
|
||||
except asyncio.TimeoutError as error:
|
||||
logger.error("Pal24 API request timeout for %s: %s", endpoint, error)
|
||||
raise Pal24APIError(f"Pal24 API request timeout for {endpoint}") from error
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("Pal24 API client error for %s: %s", endpoint, error)
|
||||
raise Pal24APIError(str(error)) from error
|
||||
|
||||
# API methods -----------------------------------------------------------------
|
||||
|
||||
async def create_bill(
|
||||
self,
|
||||
*,
|
||||
amount: Decimal,
|
||||
shop_id: str,
|
||||
order_id: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
currency_in: str = "RUB",
|
||||
type_: str = "normal",
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"amount": str(amount),
|
||||
"shop_id": shop_id,
|
||||
"currency_in": currency_in,
|
||||
"type": type_,
|
||||
}
|
||||
|
||||
if order_id:
|
||||
payload["order_id"] = order_id
|
||||
if description:
|
||||
payload["description"] = description
|
||||
|
||||
payload.update({k: v for k, v in kwargs.items() if v is not None})
|
||||
|
||||
response = await self._request("POST", "bill/create", json_payload=payload)
|
||||
return response.data
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/status", params={"id": bill_id})
|
||||
return response.data
|
||||
|
||||
async def toggle_bill_activity(self, bill_id: str, active: bool) -> Dict[str, Any]:
|
||||
payload = {"id": bill_id, "active": 1 if active else 0}
|
||||
response = await self._request("POST", "bill/toggle_activity", json_payload=payload)
|
||||
return response.data
|
||||
|
||||
async def search_payments(self, **params: Any) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "payment/search", params=params)
|
||||
return response.data
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "payment/status", params={"id": payment_id})
|
||||
return response.data
|
||||
|
||||
async def get_balance(self) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "merchant/balance")
|
||||
return response.data
|
||||
|
||||
async def search_bills(self, **params: Any) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/search", params=params)
|
||||
return response.data
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/payments", params={"id": bill_id})
|
||||
return response.data
|
||||
|
||||
# Helpers ---------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def calculate_signature(out_sum: str, inv_id: str, api_token: Optional[str] = None) -> str:
|
||||
token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN
|
||||
if not token:
|
||||
raise Pal24APIError("Pal24 signature token is not configured")
|
||||
raw = f"{out_sum}:{inv_id}:{token}".encode("utf-8")
|
||||
return hashlib.md5(raw).hexdigest().upper()
|
||||
|
||||
@staticmethod
|
||||
def verify_signature(
|
||||
out_sum: str,
|
||||
inv_id: str,
|
||||
signature: str,
|
||||
api_token: Optional[str] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token)
|
||||
except Pal24APIError:
|
||||
logger.error("Pal24 signature verification failed: missing token")
|
||||
return False
|
||||
return expected == signature.upper()
|
||||
|
||||
@staticmethod
|
||||
def normalize_amount(amount_kopeks: int) -> Decimal:
|
||||
try:
|
||||
return (Decimal(amount_kopeks) / Decimal("100")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, TypeError) as error:
|
||||
raise Pal24APIError(f"Invalid amount: {amount_kopeks}") from error
|
||||
|
||||
Vendored
+155
@@ -0,0 +1,155 @@
|
||||
"""Flask webhook server for PayPalych postbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from asyncio import AbstractEventLoop
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_payload() -> Dict[str, str]:
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
logger.warning("Pal24 webhook JSON payload не является объектом: %s", payload)
|
||||
return {}
|
||||
|
||||
if request.form:
|
||||
return {k: v for k, v in request.form.items()}
|
||||
|
||||
try:
|
||||
raw_body = request.data.decode("utf-8")
|
||||
if raw_body:
|
||||
payload = json.loads(raw_body)
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Pal24 webhook body не удалось распарсить как JSON")
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def create_pal24_flask_app(
|
||||
payment_service: PaymentService,
|
||||
loop: AbstractEventLoop,
|
||||
) -> Flask:
|
||||
pal24_service = Pal24Service()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["POST"])
|
||||
def pal24_webhook() -> tuple:
|
||||
if not pal24_service.is_configured:
|
||||
logger.error("Pal24 webhook получен, но сервис не настроен")
|
||||
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning("Пустой Pal24 webhook")
|
||||
return jsonify({"status": "error", "reason": "empty_payload"}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_postback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка валидации Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
|
||||
async def process() -> bool:
|
||||
async for db in get_db():
|
||||
try:
|
||||
return await payment_service.process_pal24_postback(db, parsed_payload)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result()
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": "internal_error"}), 500
|
||||
|
||||
if processed:
|
||||
return jsonify({"status": "ok"}), 200
|
||||
return jsonify({"status": "error", "reason": "not_processed"}), 400
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["GET"])
|
||||
def pal24_health() -> tuple:
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "pal24_webhook",
|
||||
"enabled": settings.is_pal24_enabled(),
|
||||
}), 200
|
||||
|
||||
@app.route("/pal24/health", methods=["GET"])
|
||||
def pal24_additional_health() -> tuple:
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "pal24_webhook",
|
||||
"path": settings.PAL24_WEBHOOK_PATH,
|
||||
}), 200
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 postbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
self._server: Optional[Any] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._server:
|
||||
logger.warning("Pal24 webhook server уже запущен")
|
||||
return
|
||||
|
||||
self._server = make_server(
|
||||
host="0.0.0.0",
|
||||
port=settings.PAL24_WEBHOOK_PORT,
|
||||
app=self.app,
|
||||
threaded=True,
|
||||
)
|
||||
|
||||
def _serve() -> None:
|
||||
logger.info(
|
||||
"Pal24 webhook сервер запущен на %s:%s%s",
|
||||
"0.0.0.0",
|
||||
settings.PAL24_WEBHOOK_PORT,
|
||||
settings.PAL24_WEBHOOK_PATH,
|
||||
)
|
||||
self._server.serve_forever()
|
||||
|
||||
self._thread = threading.Thread(target=_serve, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server:
|
||||
logger.info("Останавливаем Pal24 webhook сервер")
|
||||
self._server.shutdown()
|
||||
self._server = None
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
|
||||
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
|
||||
loop = asyncio.get_running_loop()
|
||||
server = Pal24WebhookServer(payment_service, loop)
|
||||
await loop.run_in_executor(None, server.start)
|
||||
return server
|
||||
|
||||
Vendored
+18
-3
@@ -4,7 +4,6 @@ import ssl
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Union, Any
|
||||
from urllib.parse import urlparse
|
||||
import aiohttp
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
@@ -58,6 +57,8 @@ class RemnaWaveUser:
|
||||
ss_password: Optional[str] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_triggered_threshold: int = 0
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -92,6 +93,8 @@ class SubscriptionInfo:
|
||||
ss_conf_links: Dict[str, str]
|
||||
subscription_url: str
|
||||
happ: Optional[Dict[str, str]]
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
@@ -584,6 +587,10 @@ class RemnaWaveAPI:
|
||||
|
||||
|
||||
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
|
||||
happ_data = user_data.get('happ') or {}
|
||||
happ_link = happ_data.get('link') or happ_data.get('url')
|
||||
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
|
||||
|
||||
return RemnaWaveUser(
|
||||
uuid=user_data['uuid'],
|
||||
short_uuid=user_data['shortUuid'],
|
||||
@@ -612,7 +619,9 @@ class RemnaWaveAPI:
|
||||
vless_uuid=user_data.get('vlessUuid'),
|
||||
ss_password=user_data.get('ssPassword'),
|
||||
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
)
|
||||
|
||||
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
@@ -645,13 +654,19 @@ class RemnaWaveAPI:
|
||||
)
|
||||
|
||||
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
|
||||
happ_data = data.get('happ') or {}
|
||||
happ_link = happ_data.get('link') or happ_data.get('url')
|
||||
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
|
||||
|
||||
return SubscriptionInfo(
|
||||
is_found=data['isFound'],
|
||||
user=data.get('user'),
|
||||
links=data.get('links', []),
|
||||
ss_conf_links=data.get('ssConfLinks', {}),
|
||||
subscription_url=data.get('subscriptionUrl', ''),
|
||||
happ=data.get('happ')
|
||||
happ=data.get('happ'),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
)
|
||||
|
||||
|
||||
|
||||
Vendored
+148
-5
@@ -1,12 +1,17 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
from typing import Optional, Iterable
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.tribute_service import TributeService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,18 +30,25 @@ class WebhookServer:
|
||||
self.app = web.Application()
|
||||
|
||||
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
|
||||
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_post(settings.MULENPAY_WEBHOOK_PATH, self._mulenpay_webhook_handler)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
@@ -62,6 +74,10 @@ class WebhookServer:
|
||||
|
||||
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}")
|
||||
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}"
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
|
||||
@@ -89,12 +105,139 @@ class WebhookServer:
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
|
||||
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)
|
||||
|
||||
if not self._verify_mulenpay_signature(request, raw_body):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
async for db in get_db():
|
||||
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
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
|
||||
|
||||
@staticmethod
|
||||
def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]:
|
||||
for header_name in header_names:
|
||||
value = request.headers.get(header_name)
|
||||
if value:
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
|
||||
secret_key = settings.MULENPAY_SECRET_KEY
|
||||
if not secret_key:
|
||||
logger.error("Mulen Pay secret key is not configured")
|
||||
return False
|
||||
|
||||
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',
|
||||
)
|
||||
)
|
||||
if signature:
|
||||
normalized_signature = signature
|
||||
if normalized_signature.lower().startswith('sha256='):
|
||||
normalized_signature = normalized_signature.split('=', 1)[1].strip()
|
||||
|
||||
hmac_digest = hmac.new(
|
||||
secret_key.encode('utf-8'),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
expected_hex_signature = hmac_digest.hex()
|
||||
expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip()
|
||||
expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip()
|
||||
|
||||
normalized_signature_lower = normalized_signature.lower()
|
||||
if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()):
|
||||
return True
|
||||
|
||||
normalized_signature_no_padding = normalized_signature.rstrip('=')
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
logger.error("Неверная подпись Mulen Pay webhook")
|
||||
return False
|
||||
|
||||
authorization_header = request.headers.get('Authorization')
|
||||
if authorization_header:
|
||||
scheme, _, value = authorization_header.partition(' ')
|
||||
scheme_lower = scheme.lower()
|
||||
token = value.strip() if value else scheme.strip()
|
||||
|
||||
if scheme_lower in ('bearer', 'token'):
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
|
||||
return False
|
||||
|
||||
if not value and hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
fallback_token = WebhookServer._extract_mulenpay_header(
|
||||
request,
|
||||
(
|
||||
'X-MulenPay-Token',
|
||||
'X-Mulenpay-Token',
|
||||
'X-Webhook-Token',
|
||||
)
|
||||
)
|
||||
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
"Mulen Pay webhook headers received: %s",
|
||||
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
|
||||
)
|
||||
|
||||
logger.error("Отсутствует подпись Mulen Pay webhook")
|
||||
return False
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
try:
|
||||
logger.info(f"Получен Tribute webhook: {request.method} {request.path}")
|
||||
logger.info(f"Headers: {dict(request.headers)}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -333,6 +333,35 @@ async def show_campaign_detail(
|
||||
f"• Выдано баланса: <b>{texts.format_price(stats['balance_issued'])}</b>"
|
||||
)
|
||||
text.append(f"• Выдано подписок: <b>{stats['subscription_issued']}</b>")
|
||||
text.append(
|
||||
f"• Доход: <b>{texts.format_price(stats['total_revenue_kopeks'])}</b>"
|
||||
)
|
||||
text.append(
|
||||
"• Получили триал: "
|
||||
f"<b>{stats['trial_users_count']}</b>"
|
||||
f" (активно: {stats['active_trials_count']})"
|
||||
)
|
||||
text.append(
|
||||
"• Конверсий в оплату: "
|
||||
f"<b>{stats['conversion_count']}</b>"
|
||||
f" / пользователей с оплатой: {stats['paid_users_count']}"
|
||||
)
|
||||
text.append(
|
||||
"• Конверсия в оплату: "
|
||||
f"<b>{stats['conversion_rate']:.1f}%</b>"
|
||||
)
|
||||
text.append(
|
||||
"• Конверсия триала: "
|
||||
f"<b>{stats['trial_conversion_rate']:.1f}%</b>"
|
||||
)
|
||||
text.append(
|
||||
"• Средний доход на пользователя: "
|
||||
f"<b>{texts.format_price(stats['avg_revenue_per_user_kopeks'])}</b>"
|
||||
)
|
||||
text.append(
|
||||
"• Средний первый платеж: "
|
||||
f"<b>{texts.format_price(stats['avg_first_payment_kopeks'])}</b>"
|
||||
)
|
||||
if stats["last_registration"]:
|
||||
text.append(
|
||||
f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}"
|
||||
|
||||
+154
-15
@@ -10,13 +10,18 @@ from app.keyboards.admin import (
|
||||
get_admin_users_submenu_keyboard,
|
||||
get_admin_promo_submenu_keyboard,
|
||||
get_admin_communications_submenu_keyboard,
|
||||
get_admin_support_submenu_keyboard,
|
||||
get_admin_settings_submenu_keyboard,
|
||||
get_admin_system_submenu_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.handlers.admin import support_settings as support_settings_handlers
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.database.crud.rules import clear_all_rules, get_rules_statistics
|
||||
from app.localization.texts import clear_rules_cache
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,10 +71,10 @@ async def show_users_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"👥 **Управление пользователями и подписками**\n\n"
|
||||
"Выберите нужный раздел:",
|
||||
texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") +
|
||||
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
|
||||
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -84,10 +89,10 @@ async def show_promo_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"💰 **Промокоды и статистика**\n\n"
|
||||
"Выберите нужный раздел:",
|
||||
texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") +
|
||||
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
|
||||
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -102,16 +107,135 @@ async def show_communications_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 **Коммуникации**\n\n"
|
||||
"Управление рассылками и текстами интерфейса:",
|
||||
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") +
|
||||
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"),
|
||||
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
# Moderators have access only to tickets and not to settings
|
||||
is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
|
||||
kb = get_admin_support_submenu_keyboard(db_user.language)
|
||||
if is_moderator_only:
|
||||
# Rebuild keyboard to include only tickets and back to main menu
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + (
|
||||
texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.")
|
||||
if is_moderator_only
|
||||
else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:")
|
||||
),
|
||||
reply_markup=kb,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# Moderator panel entry (from main menu quick button)
|
||||
async def show_moderator_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑⚖️ <b>Модерация поддержки</b>") + "\n\n" +
|
||||
texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."),
|
||||
parse_mode="HTML",
|
||||
reply_markup=kb
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_audit(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
# pagination
|
||||
page = 1
|
||||
if callback.data.startswith("admin_support_audit_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
page = 1
|
||||
per_page = 10
|
||||
total = await TicketCRUD.count_support_audit(db)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
offset = (page - 1) * per_page
|
||||
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
|
||||
|
||||
lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 <b>Аудит модераторов</b>"), ""]
|
||||
if not logs:
|
||||
lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто"))
|
||||
else:
|
||||
for log in logs:
|
||||
role = (
|
||||
texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор")
|
||||
if getattr(log, 'is_moderator', False)
|
||||
else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ")
|
||||
)
|
||||
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
|
||||
action_map = {
|
||||
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
|
||||
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
|
||||
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
|
||||
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
|
||||
}
|
||||
action_text = action_map.get(log.action, log.action)
|
||||
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
|
||||
details = log.details or {}
|
||||
extra = ""
|
||||
if log.action == 'block_user_timed' and 'minutes' in details:
|
||||
extra = f" ({details['minutes']} мин)"
|
||||
lines.append(f"{ts} • {role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
|
||||
|
||||
# keyboard with pagination
|
||||
nav_row = []
|
||||
if total_pages > 1:
|
||||
if page > 1:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}"))
|
||||
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page"))
|
||||
if page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}"))
|
||||
|
||||
kb_rows = []
|
||||
if nav_row:
|
||||
kb_rows.append(nav_row)
|
||||
kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")])
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
|
||||
|
||||
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_settings_submenu(
|
||||
@@ -120,10 +244,10 @@ async def show_settings_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⚙️ **Настройки системы**\n\n"
|
||||
"Управление Remnawave, мониторингом и другими настройками:",
|
||||
texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") +
|
||||
texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"),
|
||||
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -138,10 +262,10 @@ async def show_system_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🛠️ **Системные функции**\n\n"
|
||||
"Обновления, резервные копии и системные операции:",
|
||||
texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") +
|
||||
texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"),
|
||||
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -284,6 +408,15 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "admin_submenu_communications"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_support_submenu,
|
||||
F.data == "admin_submenu_support"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_support_audit,
|
||||
F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_settings_submenu,
|
||||
F.data == "admin_submenu_settings"
|
||||
@@ -293,6 +426,12 @@ def register_handlers(dp: Dispatcher):
|
||||
show_system_submenu,
|
||||
F.data == "admin_submenu_system"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_moderator_panel,
|
||||
F.data == "moderator_panel"
|
||||
)
|
||||
# Support settings module
|
||||
support_settings_handlers.register_handlers(dp)
|
||||
|
||||
dp.message.register(
|
||||
clear_rules_command,
|
||||
|
||||
+113
-29
@@ -9,14 +9,20 @@ from sqlalchemy import select, func, and_, or_
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
BroadcastHistory,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
|
||||
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
|
||||
BROADCAST_BUTTONS, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
|
||||
BROADCAST_BUTTON_LABELS
|
||||
BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
|
||||
get_broadcast_button_config, get_broadcast_button_labels
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
@@ -25,10 +31,8 @@ from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BUTTON_CONFIG = BROADCAST_BUTTONS
|
||||
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
|
||||
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
|
||||
BUTTON_LABELS = BROADCAST_BUTTON_LABELS
|
||||
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
@@ -39,16 +43,17 @@ def get_updated_message_buttons_selector_keyboard(selected_buttons: list, langua
|
||||
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKeyboardMarkup]:
|
||||
def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]:
|
||||
selected_buttons = selected_buttons or []
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
button_config_map = get_broadcast_button_config(language)
|
||||
|
||||
for row in BUTTON_ROWS:
|
||||
row_buttons: list[types.InlineKeyboardButton] = []
|
||||
for button_key in row:
|
||||
if button_key not in selected_buttons:
|
||||
continue
|
||||
button_config = BUTTON_CONFIG[button_key]
|
||||
button_config = button_config_map[button_key]
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=button_config["text"],
|
||||
@@ -260,14 +265,21 @@ async def select_broadcast_target(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
target = callback.data.split('_')[-1]
|
||||
|
||||
raw_target = callback.data[len("broadcast_"):]
|
||||
target_aliases = {
|
||||
"no_sub": "no",
|
||||
}
|
||||
target = target_aliases.get(raw_target, raw_target)
|
||||
|
||||
target_names = {
|
||||
"all": "Всем пользователям",
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"expiring": "С истекающей подпиской"
|
||||
"expiring": "С истекающей подпиской",
|
||||
"expired": "С истекшей подпиской",
|
||||
"active_zero": "Активная подписка, трафик 0 ГБ",
|
||||
"trial_zero": "Триальная подписка, трафик 0 ГБ",
|
||||
}
|
||||
|
||||
user_count = await get_target_users_count(db, target)
|
||||
@@ -615,7 +627,8 @@ async def confirm_button_selection(
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
|
||||
|
||||
ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row]
|
||||
selected_names = [BUTTON_LABELS[key] for key in ordered_keys if key in selected_buttons]
|
||||
button_labels = get_broadcast_button_labels(db_user.language)
|
||||
selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons]
|
||||
if selected_names:
|
||||
buttons_info = f"\n📘 <b>Кнопки:</b> {', '.join(selected_names)}"
|
||||
else:
|
||||
@@ -732,7 +745,7 @@ async def confirm_broadcast(
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons)
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
@@ -817,22 +830,88 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
|
||||
async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
if target == "all":
|
||||
return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
elif target == "active":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial]
|
||||
elif target == "trial":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_trial]
|
||||
elif target == "no":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if not user.subscription or not user.subscription.is_active]
|
||||
elif target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
else:
|
||||
return []
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
|
||||
if target == "all":
|
||||
return users
|
||||
|
||||
if target == "active":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_active
|
||||
and not user.subscription.is_trial
|
||||
]
|
||||
|
||||
if target == "trial":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription and user.subscription.is_trial
|
||||
]
|
||||
|
||||
if target == "no":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if not user.subscription or not user.subscription.is_active
|
||||
]
|
||||
|
||||
if target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
|
||||
if target == "expired":
|
||||
now = datetime.utcnow()
|
||||
expired_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
}
|
||||
expired_users = []
|
||||
for user in users:
|
||||
subscription = user.subscription
|
||||
if subscription:
|
||||
if subscription.status in expired_statuses:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
if subscription.end_date <= now and not subscription.is_active:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
elif user.has_had_paid_subscription:
|
||||
expired_users.append(user)
|
||||
return expired_users
|
||||
|
||||
if target == "active_zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "trial_zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
@@ -956,7 +1035,12 @@ def get_target_name(target_type: str) -> str:
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"sub": "Без подписки",
|
||||
"expiring": "С истекающей подпиской",
|
||||
"expired": "С истекшей подпиской",
|
||||
"active_zero": "Активная подписка, трафик 0 ГБ",
|
||||
"trial_zero": "Триальная подписка, трафик 0 ГБ",
|
||||
"zero": "Подписка, трафик 0 ГБ",
|
||||
"custom_today": "Зарегистрированные сегодня",
|
||||
"custom_week": "Зарегистрированные за неделю",
|
||||
"custom_month": "Зарегистрированные за месяц",
|
||||
|
||||
@@ -2,8 +2,10 @@ import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
@@ -12,11 +14,370 @@ from app.utils.decorators import admin_required
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.states import AdminStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
|
||||
def _format_toggle(enabled: bool) -> str:
|
||||
return "🟢 Вкл" if enabled else "🔴 Выкл"
|
||||
|
||||
|
||||
def _build_notification_settings_view(language: str):
|
||||
texts = get_texts(language)
|
||||
config = NotificationSettingsService.get_config()
|
||||
|
||||
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
second_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
third_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
|
||||
trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
|
||||
trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
|
||||
trial_channel_status = _format_toggle(
|
||||
config["trial_channel_unsubscribed"].get("enabled", True)
|
||||
)
|
||||
expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
|
||||
second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
|
||||
third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
|
||||
|
||||
summary_text = (
|
||||
"🔔 <b>Уведомления пользователям</b>\n\n"
|
||||
f"• 1 час после триала: {trial_1h_status}\n"
|
||||
f"• 24 часа после триала: {trial_24h_status}\n"
|
||||
f"• Отписка от канала: {trial_channel_status}\n"
|
||||
f"• 1 день после истечения: {expired_1d_status}\n"
|
||||
f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
|
||||
f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")],
|
||||
[InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")],
|
||||
[InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")],
|
||||
[InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")],
|
||||
[InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: скидка 2-3 день", callback_data="admin_mon_notify_preview_expired_2d")],
|
||||
[InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")],
|
||||
[InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")],
|
||||
[InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: скидка спустя дни", callback_data="admin_mon_notify_preview_expired_nd")],
|
||||
[InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")],
|
||||
[InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")],
|
||||
[InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")],
|
||||
[InlineKeyboardButton(text="🧪 Отправить все тесты", callback_data="admin_mon_notify_preview_all")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")],
|
||||
])
|
||||
|
||||
return summary_text, keyboard
|
||||
|
||||
|
||||
def _build_notification_preview_message(language: str, notification_type: str):
|
||||
texts = get_texts(language)
|
||||
now = datetime.now()
|
||||
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
header = "🧪 <b>Тестовое уведомление мониторинга</b>\n\n"
|
||||
|
||||
if notification_type == "trial_inactive_1h":
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_1H",
|
||||
(
|
||||
"⏳ <b>Прошёл час, а подключения нет</b>\n\n"
|
||||
"Если возникли сложности с запуском — воспользуйтесь инструкциями."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
price=price_30_days,
|
||||
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "trial_inactive_24h":
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_24H",
|
||||
(
|
||||
"⏳ <b>Вы ещё не подключились к VPN</b>\n\n"
|
||||
"Прошли сутки с активации тестового периода, но трафик не зафиксирован."
|
||||
"\n\nНажмите кнопку ниже, чтобы подключиться."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
price=price_30_days,
|
||||
end_date=(now + timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "trial_channel_unsubscribed":
|
||||
template = texts.get(
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED",
|
||||
(
|
||||
"🚫 <b>Доступ приостановлен</b>\n\n"
|
||||
"Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
|
||||
"Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
|
||||
),
|
||||
)
|
||||
check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
|
||||
message = template.format(check_button=check_button)
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
if settings.CHANNEL_LINK:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
||||
url=settings.CHANNEL_LINK,
|
||||
)
|
||||
]
|
||||
)
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=check_button,
|
||||
callback_data="sub_channel_check",
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
elif notification_type == "expired_1d":
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_1D",
|
||||
(
|
||||
"⛔ <b>Подписка закончилась</b>\n\n"
|
||||
"Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
end_date=(now - timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
|
||||
price=price_30_days,
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "expired_2d":
|
||||
percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
|
||||
(
|
||||
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
|
||||
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
|
||||
"Предложение действует до {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,
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Получить скидку",
|
||||
callback_data="claim_discount_preview",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "expired_nd":
|
||||
percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
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}."
|
||||
),
|
||||
)
|
||||
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"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Получить скидку",
|
||||
callback_data="claim_discount_preview",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported notification type: {notification_type}")
|
||||
|
||||
footer = "\n\n<i>Сообщение отправлено только вам для проверки оформления.</i>"
|
||||
return header + message + footer, keyboard
|
||||
|
||||
|
||||
async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None:
|
||||
message, keyboard = _build_notification_preview_message(language, notification_type)
|
||||
await bot.send_message(
|
||||
chat_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
async def _render_notification_settings(callback: CallbackQuery) -> None:
|
||||
language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
text, keyboard = _build_notification_settings_view(language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
|
||||
async def _render_notification_settings_for_state(
|
||||
bot,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
language: str,
|
||||
business_connection_id: str | None = None,
|
||||
) -> None:
|
||||
text, keyboard = _build_notification_settings_view(language)
|
||||
|
||||
edit_kwargs = {
|
||||
"text": text,
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": keyboard,
|
||||
}
|
||||
|
||||
if business_connection_id:
|
||||
edit_kwargs["business_connection_id"] = business_connection_id
|
||||
|
||||
try:
|
||||
await bot.edit_message_text(**edit_kwargs)
|
||||
except TelegramBadRequest as exc:
|
||||
if "no text in the message to edit" in (exc.message or "").lower():
|
||||
caption_kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"caption": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": keyboard,
|
||||
}
|
||||
|
||||
if business_connection_id:
|
||||
caption_kwargs["business_connection_id"] = business_connection_id
|
||||
|
||||
await bot.edit_message_caption(**caption_kwargs)
|
||||
else:
|
||||
raise
|
||||
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
@@ -43,7 +404,8 @@ async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
🔧 Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = get_monitoring_keyboard()
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
keyboard = get_monitoring_keyboard(language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
@@ -52,6 +414,287 @@ async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
await callback.answer("❌ Ошибка получения данных", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_settings")
|
||||
@admin_required
|
||||
async def admin_monitoring_settings(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены"
|
||||
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
third_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
|
||||
text = (
|
||||
"⚙️ <b>Настройки мониторинга</b>\n\n"
|
||||
f"🔔 <b>Уведомления пользователям:</b> {global_status}\n"
|
||||
f"• Скидка 2-3 дня: {second_percent}%\n"
|
||||
f"• Скидка после {third_days} дней: {third_percent}%\n\n"
|
||||
"Выберите раздел для настройки."
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")],
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отображения настроек мониторинга: {e}")
|
||||
await callback.answer("❌ Не удалось открыть настройки", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_settings")
|
||||
@admin_required
|
||||
async def admin_notify_settings(callback: CallbackQuery):
|
||||
try:
|
||||
await _render_notification_settings(callback)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отображения настроек уведомлений: {e}")
|
||||
await callback.answer("❌ Не удалось загрузить настройки", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h")
|
||||
@admin_required
|
||||
async def toggle_trial_1h_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
|
||||
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_1h")
|
||||
@admin_required
|
||||
async def preview_trial_1h_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_1h")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial 1h preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h")
|
||||
@admin_required
|
||||
async def toggle_trial_24h_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
|
||||
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_24h")
|
||||
@admin_required
|
||||
async def preview_trial_24h_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_24h")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial 24h preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel")
|
||||
@admin_required
|
||||
async def toggle_trial_channel_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
|
||||
NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel")
|
||||
@admin_required
|
||||
async def preview_trial_channel_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial channel preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
|
||||
@admin_required
|
||||
async def toggle_expired_1d_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_expired_1d_enabled()
|
||||
NotificationSettingsService.set_expired_1d_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_1d")
|
||||
@admin_required
|
||||
async def preview_expired_1d_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_1d")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send expired 1d preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d")
|
||||
@admin_required
|
||||
async def toggle_second_wave_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_second_wave_enabled()
|
||||
NotificationSettingsService.set_second_wave_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_2d")
|
||||
@admin_required
|
||||
async def preview_second_wave_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_2d")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send second wave preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd")
|
||||
@admin_required
|
||||
async def toggle_third_wave_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_third_wave_enabled()
|
||||
NotificationSettingsService.set_third_wave_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_nd")
|
||||
@admin_required
|
||||
async def preview_third_wave_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_nd")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send third wave preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_all")
|
||||
@admin_required
|
||||
async def preview_all_notifications(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
chat_id = callback.from_user.id
|
||||
for notification_type in [
|
||||
"trial_inactive_1h",
|
||||
"trial_inactive_24h",
|
||||
"trial_channel_unsubscribed",
|
||||
"expired_1d",
|
||||
"expired_2d",
|
||||
"expired_nd",
|
||||
]:
|
||||
await _send_notification_preview(callback.bot, chat_id, language, notification_type)
|
||||
await callback.answer("✅ Все тестовые уведомления отправлены")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send all notification previews: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тесты", show_alert=True)
|
||||
|
||||
|
||||
async def _start_notification_value_edit(
|
||||
callback: CallbackQuery,
|
||||
state: FSMContext,
|
||||
setting_key: str,
|
||||
field: str,
|
||||
prompt_key: str,
|
||||
default_prompt: str,
|
||||
):
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await state.set_state(AdminStates.editing_notification_value)
|
||||
await state.update_data(
|
||||
notification_setting_key=setting_key,
|
||||
notification_setting_field=field,
|
||||
settings_message_chat=callback.message.chat.id,
|
||||
settings_message_id=callback.message.message_id,
|
||||
settings_business_connection_id=(
|
||||
str(getattr(callback.message, "business_connection_id", None))
|
||||
if getattr(callback.message, "business_connection_id", None) is not None
|
||||
else None
|
||||
),
|
||||
settings_language=language,
|
||||
)
|
||||
texts = get_texts(language)
|
||||
await callback.answer()
|
||||
await callback.message.answer(texts.get(prompt_key, default_prompt))
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent")
|
||||
@admin_required
|
||||
async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_second_wave",
|
||||
"percent",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT",
|
||||
"Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours")
|
||||
@admin_required
|
||||
async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_second_wave",
|
||||
"hours",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS",
|
||||
"Введите количество часов действия скидки (1-168):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent")
|
||||
@admin_required
|
||||
async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"percent",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT",
|
||||
"Введите новый процент скидки для позднего предложения (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours")
|
||||
@admin_required
|
||||
async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"hours",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS",
|
||||
"Введите количество часов действия скидки (1-168):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold")
|
||||
@admin_required
|
||||
async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"trigger",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS",
|
||||
"Через сколько дней после истечения отправлять предложение? (минимум 2):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_start")
|
||||
@admin_required
|
||||
async def start_monitoring_callback(callback: CallbackQuery):
|
||||
@@ -366,5 +1009,74 @@ async def monitoring_command(message: Message):
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(AdminStates.editing_notification_value)
|
||||
async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
if not data:
|
||||
await state.clear()
|
||||
await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.")
|
||||
return
|
||||
|
||||
raw_value = (message.text or "").strip()
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число."))
|
||||
return
|
||||
|
||||
key = data.get("notification_setting_key")
|
||||
field = data.get("notification_setting_field")
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
success = False
|
||||
if key == "expired_second_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_second_wave_discount_percent(value)
|
||||
elif key == "expired_second_wave" and field == "hours":
|
||||
success = NotificationSettingsService.set_second_wave_valid_hours(value)
|
||||
elif key == "expired_third_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_third_wave_discount_percent(value)
|
||||
elif key == "expired_third_wave" and field == "hours":
|
||||
success = NotificationSettingsService.set_third_wave_valid_hours(value)
|
||||
elif key == "expired_third_wave" and field == "trigger":
|
||||
success = NotificationSettingsService.set_third_wave_trigger_days(value)
|
||||
|
||||
if not success:
|
||||
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова."))
|
||||
return
|
||||
|
||||
back_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.get("BACK", "⬅️ Назад"),
|
||||
callback_data="admin_mon_notify_settings",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены."),
|
||||
reply_markup=back_keyboard,
|
||||
)
|
||||
|
||||
chat_id = data.get("settings_message_chat")
|
||||
message_id = data.get("settings_message_id")
|
||||
business_connection_id = data.get("settings_business_connection_id")
|
||||
if chat_id and message_id:
|
||||
await _render_notification_settings_for_state(
|
||||
message.bot,
|
||||
chat_id,
|
||||
message_id,
|
||||
language,
|
||||
business_connection_id=business_connection_id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
dp.include_router(router)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,14 +30,22 @@ async def show_remnawave_menu(
|
||||
):
|
||||
remnawave_service = RemnaWaveService()
|
||||
connection_test = await remnawave_service.test_api_connection()
|
||||
|
||||
status_emoji = "✅" if connection_test["status"] == "connected" else "❌"
|
||||
|
||||
|
||||
status = connection_test.get("status")
|
||||
if status == "connected":
|
||||
status_emoji = "✅"
|
||||
elif status == "not_configured":
|
||||
status_emoji = "ℹ️"
|
||||
else:
|
||||
status_emoji = "❌"
|
||||
|
||||
api_url_display = settings.REMNAWAVE_API_URL or "—"
|
||||
|
||||
text = f"""
|
||||
🖥️ <b>Управление Remnawave</b>
|
||||
|
||||
📡 <b>Соединение:</b> {status_emoji} {connection_test["message"]}
|
||||
🌐 <b>URL:</b> <code>{settings.REMNAWAVE_API_URL}</code>
|
||||
📡 <b>Соединение:</b> {status_emoji} {connection_test.get("message", "Нет данных")}
|
||||
🌐 <b>URL:</b> <code>{api_url_display}</code>
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
@@ -194,7 +202,7 @@ async def show_traffic_stats(
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
try:
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
bandwidth_stats = await api.get_bandwidth_stats()
|
||||
|
||||
realtime_usage = await api.get_nodes_realtime_usage()
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import (
|
||||
get_admin_report_result_keyboard,
|
||||
get_admin_reports_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.reporting_service import (
|
||||
ReportPeriod,
|
||||
ReportingServiceError,
|
||||
reporting_service,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_reports_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await callback.message.edit_text(
|
||||
"📊 <b>Отчеты</b>\n\n"
|
||||
"Выберите период, чтобы отправить отчет в админский топик.",
|
||||
reply_markup=get_admin_reports_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_daily_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.DAILY, db_user.language)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_weekly_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.WEEKLY, db_user.language)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_monthly_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.MONTHLY, db_user.language)
|
||||
|
||||
|
||||
async def _send_report(
|
||||
callback: types.CallbackQuery,
|
||||
period: ReportPeriod,
|
||||
language: str,
|
||||
) -> None:
|
||||
try:
|
||||
report_text = await reporting_service.send_report(period, send_to_topic=True)
|
||||
except ReportingServiceError as exc:
|
||||
logger.warning("Не удалось отправить отчет: %s", exc)
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Непредвиденная ошибка при отправке отчета: %s", exc)
|
||||
await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.answer(
|
||||
report_text,
|
||||
reply_markup=get_admin_report_result_keyboard(language),
|
||||
)
|
||||
await callback.answer("Отчет отправлен в топик")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def close_report_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||
logger.warning("Не удалось закрыть сообщение отчета: %s", exc)
|
||||
await callback.answer(texts.t("REPORT_CLOSE_ERROR", "Не удалось закрыть отчет."), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer(texts.t("REPORT_CLOSED", "Отчет закрыт."))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(show_reports_menu, F.data == "admin_reports")
|
||||
dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily")
|
||||
dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly")
|
||||
dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly")
|
||||
dp.callback_query.register(close_report_message, F.data == "admin_close_report")
|
||||
|
||||
+437
-114
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -6,10 +7,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads, get_server_squad_by_id, update_server_squad,
|
||||
delete_server_squad, sync_with_remnawave, get_server_statistics,
|
||||
create_server_squad, get_available_server_squads
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_id,
|
||||
update_server_squad,
|
||||
delete_server_squad,
|
||||
sync_with_remnawave,
|
||||
get_server_statistics,
|
||||
create_server_squad,
|
||||
get_available_server_squads,
|
||||
update_server_squad_promo_groups,
|
||||
get_server_connected_users,
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.cache import cache
|
||||
@@ -17,6 +26,116 @@ from app.utils.cache import cache
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_server_edit_view(server):
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
promo_groups_text = (
|
||||
", ".join(sorted(pg.name for pg in server.allowed_promo_groups))
|
||||
if server.allowed_promo_groups
|
||||
else "Не выбраны"
|
||||
)
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
• Промогруппы: {promo_groups_text}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list"),
|
||||
],
|
||||
]
|
||||
|
||||
return text, types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _build_server_promo_groups_keyboard(server_id: int, promo_groups, selected_ids):
|
||||
keyboard = []
|
||||
for group in promo_groups:
|
||||
emoji = "✅" if group["id"] in selected_ids else "⚪"
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{emoji} {group['name']}",
|
||||
callback_data=f"admin_server_promo_toggle_{server_id}_{group['id']}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💾 Сохранить", callback_data=f"admin_server_promo_save_{server_id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=f"admin_server_edit_{server_id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_servers_menu(
|
||||
@@ -164,9 +283,9 @@ async def sync_servers_with_remnawave(
|
||||
)
|
||||
return
|
||||
|
||||
created, updated, disabled = await sync_with_remnawave(db, squads)
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
text = f"""
|
||||
✅ <b>Синхронизация завершена</b>
|
||||
@@ -174,7 +293,7 @@ async def sync_servers_with_remnawave(
|
||||
📊 <b>Результаты:</b>
|
||||
• Создано новых серверов: {created}
|
||||
• Обновлено существующих: {updated}
|
||||
• Отключено неактивных: {disabled}
|
||||
• Удалено отсутствующих: {removed}
|
||||
• Всего обработано: {len(squads)}
|
||||
|
||||
ℹ️ Новые серверы созданы как недоступные.
|
||||
@@ -202,7 +321,7 @@ async def sync_servers_with_remnawave(
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -216,65 +335,161 @@ async def show_server_edit_menu(
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_users(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
payload = callback.data.split("admin_server_users_", 1)[-1]
|
||||
payload_parts = payload.split("_")
|
||||
|
||||
server_id = int(payload_parts[0])
|
||||
page = int(payload_parts[1]) if len(payload_parts) > 1 else 1
|
||||
page = max(page, 1)
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
users = await get_server_connected_users(db, server_id)
|
||||
total_users = len(users)
|
||||
|
||||
page_size = 10
|
||||
total_pages = max((total_users + page_size - 1) // page_size, 1)
|
||||
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
start_index = (page - 1) * page_size
|
||||
end_index = start_index + page_size
|
||||
page_users = users[start_index:end_index]
|
||||
|
||||
safe_name = html.escape(server.display_name or "—")
|
||||
safe_uuid = html.escape(server.squad_uuid or "—")
|
||||
|
||||
header = [
|
||||
"🌐 <b>Пользователи сервера</b>",
|
||||
"",
|
||||
f"• Сервер: {safe_name}",
|
||||
f"• UUID: <code>{safe_uuid}</code>",
|
||||
f"• Подключений: {total_users}",
|
||||
]
|
||||
|
||||
|
||||
if total_pages > 1:
|
||||
header.append(f"• Страница: {page}/{total_pages}")
|
||||
|
||||
header.append("")
|
||||
|
||||
text = "\n".join(header)
|
||||
|
||||
def _get_status_icon(status_text: str) -> str:
|
||||
if not status_text:
|
||||
return ""
|
||||
|
||||
parts = status_text.split(" ", 1)
|
||||
return parts[0] if parts else status_text
|
||||
|
||||
if users:
|
||||
lines = []
|
||||
for index, user in enumerate(page_users, start=start_index + 1):
|
||||
safe_user_name = html.escape(user.full_name)
|
||||
lines.append(f"{index}. {safe_user_name}")
|
||||
|
||||
text += "\n" + "\n".join(lines)
|
||||
else:
|
||||
text += "Пользователи не найдены."
|
||||
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
for user in page_users:
|
||||
display_name = user.full_name
|
||||
if len(display_name) > 30:
|
||||
display_name = display_name[:27] + "..."
|
||||
|
||||
subscription_status = (
|
||||
user.subscription.status_display
|
||||
if user.subscription
|
||||
else "❌ Нет подписки"
|
||||
)
|
||||
status_icon = _get_status_icon(subscription_status)
|
||||
|
||||
if status_icon:
|
||||
button_text = f"{status_icon} {display_name}"
|
||||
else:
|
||||
button_text = display_name
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}",
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_buttons: list[types.InlineKeyboardButton] = []
|
||||
|
||||
if page > 1:
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Предыдущая",
|
||||
callback_data=f"admin_server_users_{server_id}_{page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"Стр. {page}/{total_pages}",
|
||||
callback_data=f"admin_server_users_{server_id}_{page}",
|
||||
)
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="Следующая ➡️",
|
||||
callback_data=f"admin_server_users_{server_id}_{page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(navigation_buttons)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ К серверу", callback_data=f"admin_server_edit_{server_id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ К списку", callback_data="admin_servers_list"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -296,65 +511,18 @@ async def toggle_server_availability(
|
||||
new_status = not server.is_available
|
||||
await update_server_squad(db, server_id, is_available=new_status)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
status_text = "включен" if new_status else "отключен"
|
||||
await callback.answer(f"✅ Сервер {status_text}!")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -422,7 +590,7 @@ async def process_server_price_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно"
|
||||
await message.answer(
|
||||
@@ -497,7 +665,7 @@ async def process_server_name_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Название сервера изменено на: <b>{new_name}</b>",
|
||||
@@ -570,7 +738,7 @@ async def delete_server_execute(
|
||||
success = await delete_server_squad(db, server_id)
|
||||
|
||||
if success:
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Сервер <b>{server.display_name}</b> успешно удален!",
|
||||
@@ -701,7 +869,7 @@ async def process_server_country_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
country_text = new_country or "Удален"
|
||||
await message.answer(
|
||||
@@ -847,11 +1015,12 @@ async def process_server_description_edit(
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, description=new_description)
|
||||
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
|
||||
desc_text = new_description or "Удалено"
|
||||
await cache.delete_pattern("available_countries*")
|
||||
await message.answer(
|
||||
f"✅ Описание сервера изменено:\n\n<i>{desc_text}</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -862,6 +1031,147 @@ async def process_server_description_edit(
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_promo_groups(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
promo_groups_data = await get_promo_groups_with_counts(db)
|
||||
promo_groups = [
|
||||
{"id": group.id, "name": group.name, "is_default": group.is_default}
|
||||
for group, _ in promo_groups_data
|
||||
]
|
||||
|
||||
if not promo_groups:
|
||||
await callback.answer("❌ Не найдены промогруппы", show_alert=True)
|
||||
return
|
||||
|
||||
selected_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
if not selected_ids:
|
||||
default_group = next((pg for pg in promo_groups if pg["is_default"]), None)
|
||||
if default_group:
|
||||
selected_ids.add(default_group["id"])
|
||||
|
||||
await state.set_state(AdminStates.editing_server_promo_groups)
|
||||
await state.set_data(
|
||||
{
|
||||
"server_id": server_id,
|
||||
"promo_groups": promo_groups,
|
||||
"selected_promo_groups": list(selected_ids),
|
||||
"server_name": server.display_name,
|
||||
}
|
||||
)
|
||||
|
||||
text = (
|
||||
"🎯 <b>Настройка промогрупп</b>\n\n"
|
||||
f"Сервер: <b>{server.display_name}</b>\n\n"
|
||||
"Выберите промогруппы, которым будет доступен этот сервер.\n"
|
||||
"Должна быть выбрана минимум одна промогруппа."
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
parts = callback.data.split('_')
|
||||
server_id = int(parts[4])
|
||||
group_id = int(parts[5])
|
||||
|
||||
data = await state.get_data()
|
||||
if not data or data.get("server_id") != server_id:
|
||||
await callback.answer("⚠️ Сессия редактирования устарела", show_alert=True)
|
||||
return
|
||||
|
||||
selected = set(int(pg_id) for pg_id in data.get("selected_promo_groups", []))
|
||||
promo_groups = data.get("promo_groups", [])
|
||||
|
||||
if group_id in selected:
|
||||
if len(selected) == 1:
|
||||
await callback.answer("⚠️ Нельзя отключить последнюю промогруппу", show_alert=True)
|
||||
return
|
||||
selected.remove(group_id)
|
||||
message = "Промогруппа отключена"
|
||||
else:
|
||||
selected.add(group_id)
|
||||
message = "Промогруппа добавлена"
|
||||
|
||||
await state.update_data(selected_promo_groups=list(selected))
|
||||
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected)
|
||||
)
|
||||
await callback.answer(message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def save_server_promo_groups(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
if not data:
|
||||
await callback.answer("⚠️ Нет данных для сохранения", show_alert=True)
|
||||
return
|
||||
|
||||
server_id = data.get("server_id")
|
||||
selected = data.get("selected_promo_groups", [])
|
||||
|
||||
if not selected:
|
||||
await callback.answer("❌ Выберите хотя бы одну промогруппу", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
server = await update_server_squad_promo_groups(db, server_id, selected)
|
||||
except ValueError as exc:
|
||||
await callback.answer(f"❌ {exc}", show_alert=True)
|
||||
return
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await cache.delete_pattern("available_countries*")
|
||||
await state.clear()
|
||||
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer("✅ Промогруппы обновлены!")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_server_user_counts_handler(
|
||||
@@ -934,12 +1244,23 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts")
|
||||
dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats")
|
||||
|
||||
dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc"))
|
||||
dp.callback_query.register(
|
||||
show_server_edit_menu,
|
||||
F.data.startswith("admin_server_edit_")
|
||||
& ~F.data.contains("name")
|
||||
& ~F.data.contains("price")
|
||||
& ~F.data.contains("country")
|
||||
& ~F.data.contains("limit")
|
||||
& ~F.data.contains("desc")
|
||||
& ~F.data.contains("promo"),
|
||||
)
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
|
||||
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_promo_"))
|
||||
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
|
||||
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
|
||||
|
||||
@@ -947,7 +1268,9 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(process_server_price_edit, AdminStates.editing_server_price)
|
||||
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
|
||||
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_promo_toggle_"))
|
||||
dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_promo_save_"))
|
||||
|
||||
dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm"))
|
||||
dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_"))
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import logging
|
||||
import re
|
||||
import html
|
||||
import contextlib
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.states import SupportSettingsStates
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
mode = SupportSettingsService.get_system_mode()
|
||||
menu_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
admin_notif = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
user_notif = SupportSettingsService.get_user_ticket_notifications_enabled()
|
||||
sla_enabled = SupportSettingsService.get_sla_enabled()
|
||||
sla_minutes = SupportSettingsService.get_sla_minutes()
|
||||
|
||||
rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены")
|
||||
status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены")
|
||||
|
||||
def mode_button(label_key: str, default: str, active: bool) -> str:
|
||||
prefix = "🔘" if active else "⚪"
|
||||
return f"{prefix} {texts.t(label_key, default)}"
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'✅' if menu_enabled else '🚫'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}"
|
||||
),
|
||||
callback_data="admin_support_toggle_menu"
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"),
|
||||
callback_data="admin_support_mode_tickets"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"),
|
||||
callback_data="admin_support_mode_contact"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"),
|
||||
callback_data="admin_support_mode_both"
|
||||
),
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"),
|
||||
callback_data="admin_support_edit_desc"
|
||||
)
|
||||
])
|
||||
|
||||
# Notifications block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'🔔' if admin_notif else '🔕'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: "
|
||||
f"{status_enabled if admin_notif else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_admin_notifications"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'🔔' if user_notif else '🔕'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: "
|
||||
f"{status_enabled if user_notif else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_user_notifications"
|
||||
)
|
||||
])
|
||||
|
||||
# SLA block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'⏰' if sla_enabled else '⏹️'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: "
|
||||
f"{status_enabled if sla_enabled else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_sla"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes),
|
||||
callback_data="admin_support_set_sla_minutes"
|
||||
)
|
||||
])
|
||||
|
||||
# Moderators
|
||||
moderators = SupportSettingsService.get_moderators()
|
||||
mod_count = len(moderators)
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑⚖️ Модераторы: {count}").format(count=mod_count),
|
||||
callback_data="admin_support_list_moderators"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"),
|
||||
callback_data="admin_support_add_moderator"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"),
|
||||
callback_data="admin_support_remove_moderator"
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")
|
||||
])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
desc = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 <b>Настройки поддержки</b>") + "\n\n" +
|
||||
texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" +
|
||||
desc,
|
||||
reply_markup=_get_support_settings_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_support_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current = SupportSettingsService.is_support_menu_enabled()
|
||||
SupportSettingsService.set_support_menu_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_admin_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
SupportSettingsService.set_admin_ticket_notifications_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_user_ticket_notifications_enabled()
|
||||
SupportSettingsService.set_user_ticket_notifications_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_sla_enabled()
|
||||
SupportSettingsService.set_sla_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
class SupportAdvancedStates(StatesGroup):
|
||||
waiting_for_sla_minutes = State()
|
||||
waiting_for_moderator_id = State()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_SLA_SETUP_PROMPT",
|
||||
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
minutes = int(text)
|
||||
if minutes <= 0 or minutes > 1440:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)"))
|
||||
return
|
||||
SupportSettingsService.set_sla_minutes(minutes)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT",
|
||||
"🧑⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT",
|
||||
"🧑⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
# We'll reuse the same state; next message will decide action via flag
|
||||
await state.update_data(action="remove_moderator")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
action = data.get("action", "add")
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
tid = int(text)
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)"))
|
||||
return
|
||||
if action == "remove_moderator":
|
||||
ok = SupportSettingsService.remove_moderator(tid)
|
||||
msg = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid)
|
||||
if ok
|
||||
else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора")
|
||||
)
|
||||
else:
|
||||
ok = SupportSettingsService.add_moderator(tid)
|
||||
msg = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid)
|
||||
if ok
|
||||
else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора")
|
||||
)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(msg, reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
moderators = SupportSettingsService.get_moderators()
|
||||
if not moderators:
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True)
|
||||
return
|
||||
text = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑⚖️ <b>Модераторы</b>") +
|
||||
"\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
|
||||
)
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("tickets")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_contact(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("contact")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_both(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("both")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
# plain text for display-only code block
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
|
||||
kb_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"),
|
||||
callback_data="admin_support_send_desc"
|
||||
)
|
||||
])
|
||||
# Подготовим блок контакта (отдельным инлайном)
|
||||
from app.config import settings
|
||||
support_contact_display = settings.get_support_contact_display()
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")
|
||||
])
|
||||
|
||||
text_parts = [
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 <b>Редактирование описания поддержки</b>"),
|
||||
"",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"),
|
||||
"",
|
||||
f"<code>{html.escape(current_desc_plain)}</code>",
|
||||
]
|
||||
if support_contact_display:
|
||||
text_parts += [
|
||||
"",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "<b>Контакт для режима «Контакт»</b>"),
|
||||
f"<code>{html.escape(support_contact_display)}</code>",
|
||||
"",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."),
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_parts),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=kb_rows),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(SupportSettingsStates.waiting_for_desc)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.html_text or message.text
|
||||
SupportSettingsService.set_support_info_text(db_user.language, new_text)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# send plain text for easy copying
|
||||
texts = get_texts(db_user.language)
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
# attach delete button to the sent message
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
if len(current_desc_plain) <= 4000:
|
||||
await callback.message.answer(current_desc_plain, reply_markup=markup)
|
||||
else:
|
||||
# split long messages (attach delete only to the last chunk)
|
||||
chunk = 0
|
||||
while chunk < len(current_desc_plain):
|
||||
next_chunk = current_desc_plain[chunk:chunk+4000]
|
||||
is_last = (chunk + 4000) >= len(current_desc_plain)
|
||||
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
|
||||
chunk += 4000
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже"))
|
||||
|
||||
|
||||
@error_handler
|
||||
async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Allow admins and moderators to delete informational notifications
|
||||
try:
|
||||
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
except Exception:
|
||||
may_delete = False
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
if not may_delete:
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
try:
|
||||
await callback.message.delete()
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено"))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_support_settings, F.data == "admin_support_settings")
|
||||
dp.callback_query.register(toggle_support_menu, F.data == "admin_support_toggle_menu")
|
||||
dp.callback_query.register(set_mode_tickets, F.data == "admin_support_mode_tickets")
|
||||
dp.callback_query.register(set_mode_contact, F.data == "admin_support_mode_contact")
|
||||
dp.callback_query.register(set_mode_both, F.data == "admin_support_mode_both")
|
||||
dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc")
|
||||
dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc")
|
||||
dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg")
|
||||
dp.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications")
|
||||
dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications")
|
||||
dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla")
|
||||
dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes")
|
||||
dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator")
|
||||
dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator")
|
||||
dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators")
|
||||
dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc)
|
||||
dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes)
|
||||
dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOG_PREVIEW_LIMIT = 2300
|
||||
|
||||
|
||||
def _resolve_log_path() -> Path:
|
||||
log_path = Path(settings.LOG_FILE)
|
||||
if not log_path.is_absolute():
|
||||
log_path = Path.cwd() / log_path
|
||||
return log_path
|
||||
|
||||
|
||||
def _format_preview_block(text: str) -> str:
|
||||
escaped_text = escape(text) if text else ""
|
||||
return f"<blockquote expandable><pre><code>{escaped_text}</code></pre></blockquote>"
|
||||
|
||||
|
||||
def _build_logs_message(log_path: Path) -> str:
|
||||
if not log_path.exists():
|
||||
message = (
|
||||
"🧾 <b>Системные логи</b>\n\n"
|
||||
f"Файл <code>{log_path}</code> пока не создан.\n"
|
||||
"Логи появятся автоматически после первой записи."
|
||||
)
|
||||
return message
|
||||
|
||||
try:
|
||||
content = log_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception as error: # pragma: no cover - защита от проблем чтения
|
||||
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
|
||||
message = (
|
||||
"❌ <b>Ошибка чтения логов</b>\n\n"
|
||||
f"Не удалось прочитать файл <code>{log_path}</code>."
|
||||
)
|
||||
return message
|
||||
|
||||
total_length = len(content)
|
||||
stats = log_path.stat()
|
||||
updated_at = datetime.fromtimestamp(stats.st_mtime)
|
||||
|
||||
if not content:
|
||||
preview_text = "Лог-файл пуст."
|
||||
truncated = False
|
||||
else:
|
||||
preview_text = content[-LOG_PREVIEW_LIMIT:]
|
||||
truncated = total_length > LOG_PREVIEW_LIMIT
|
||||
|
||||
details_lines = [
|
||||
"🧾 <b>Системные логи</b>",
|
||||
"",
|
||||
f"📁 <b>Файл:</b> <code>{log_path}</code>",
|
||||
f"🕒 <b>Обновлен:</b> {updated_at.strftime('%d.%m.%Y %H:%M:%S')}",
|
||||
f"🧮 <b>Размер:</b> {total_length} символов",
|
||||
(
|
||||
f"👇 Показаны последние {LOG_PREVIEW_LIMIT} символов."
|
||||
if truncated
|
||||
else "📄 Показано все содержимое файла."
|
||||
),
|
||||
"",
|
||||
_format_preview_block(preview_text),
|
||||
]
|
||||
|
||||
return "\n".join(details_lines)
|
||||
|
||||
|
||||
def _get_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_system_logs_refresh")],
|
||||
[InlineKeyboardButton(text="⬇️ Скачать лог", callback_data="admin_system_logs_download")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_system")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
message = _build_logs_message(log_path)
|
||||
|
||||
reply_markup = _get_logs_keyboard()
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def refresh_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
message = _build_logs_message(log_path)
|
||||
|
||||
reply_markup = _get_logs_keyboard()
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer("🔄 Обновлено")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def download_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
await callback.answer("❌ Лог-файл не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer("⬇️ Отправляю лог...")
|
||||
|
||||
document = FSInputFile(log_path)
|
||||
stats = log_path.stat()
|
||||
updated_at = datetime.fromtimestamp(stats.st_mtime).strftime("%d.%m.%Y %H:%M:%S")
|
||||
caption = (
|
||||
f"🧾 Лог-файл <code>{log_path.name}</code>\n"
|
||||
f"📁 Путь: <code>{log_path}</code>\n"
|
||||
f"🕒 Обновлен: {updated_at}"
|
||||
)
|
||||
await callback.message.answer_document(document=document, caption=caption, parse_mode="HTML")
|
||||
except Exception as error: # pragma: no cover - защита от ошибок отправки
|
||||
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
|
||||
await callback.message.answer(
|
||||
"❌ <b>Не удалось отправить лог-файл</b>\n\n"
|
||||
"Проверьте журналы приложения или повторите попытку позже.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_system_logs,
|
||||
F.data == "admin_system_logs",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
refresh_system_logs,
|
||||
F.data == "admin_system_logs_refresh",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
download_system_logs,
|
||||
F.data == "admin_system_logs_download",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
+1167
-103
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.states import AdminStates
|
||||
from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard
|
||||
@@ -44,16 +43,16 @@ async def show_welcome_text_panel(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👋 Управление приветственным текстом\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
|
||||
f"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -89,11 +88,11 @@ async def show_current_welcome_text(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
current_text = settings['text']
|
||||
is_enabled = settings['is_enabled']
|
||||
|
||||
if not settings['id']:
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
current_text = welcome_settings['text']
|
||||
is_enabled = welcome_settings['is_enabled']
|
||||
|
||||
if not welcome_settings['id']:
|
||||
status = "📝 Используется стандартный текст:"
|
||||
else:
|
||||
status = "📝 Текущий приветственный текст:"
|
||||
@@ -121,7 +120,7 @@ async def show_placeholders_help(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
@@ -137,7 +136,7 @@ async def show_placeholders_help(
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -149,12 +148,12 @@ async def show_formatting_help(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
formatting_info = get_telegram_formatting_info()
|
||||
|
||||
await callback.message.edit_text(
|
||||
formatting_info,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -167,8 +166,8 @@ async def start_edit_welcome_text(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
current_text = settings['text']
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
current_text = welcome_settings['text']
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
|
||||
@@ -206,9 +205,9 @@ async def process_welcome_text_edit(
|
||||
success = await set_welcome_text(db, new_text, db_user.id)
|
||||
|
||||
if success:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
|
||||
@@ -219,14 +218,14 @@ async def process_welcome_text_edit(
|
||||
f"Новый текст:\n"
|
||||
f"<code>{new_text}</code>\n\n"
|
||||
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
await message.answer(
|
||||
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
@@ -242,9 +241,9 @@ async def reset_welcome_text(
|
||||
success = await set_welcome_text(db, default_text, db_user.id)
|
||||
|
||||
if success:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Приветственный текст сброшен на стандартный!\n\n"
|
||||
@@ -252,14 +251,14 @@ async def reset_welcome_text(
|
||||
f"Стандартный текст:\n"
|
||||
f"<code>{default_text}</code>\n\n"
|
||||
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@@ -281,14 +280,14 @@ async def show_preview_welcome_text(
|
||||
test_user = TestUser()
|
||||
preview_text = await get_welcome_text_for_user(db, test_user)
|
||||
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
|
||||
if preview_text:
|
||||
await callback.message.edit_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
|
||||
f"<code>{preview_text}</code>",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
@@ -296,7 +295,7 @@ async def show_preview_welcome_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"🔴 Приветственные сообщения отключены.\n"
|
||||
f"Новые пользователи не будут получать приветственный текст после регистрации.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
+671
-3
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -332,6 +333,84 @@ async def start_yookassa_sbp_payment(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_mulenpay_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
"MULENPAY_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
|
||||
"Оплата происходит через защищенную платформу Mulen Pay."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="mulenpay")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
"🏦 <b>Оплата через PayPalych (СБП)</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
"Оплата проходит через систему быстрых платежей PayPalych."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -523,10 +602,18 @@ async def process_topup_amount(
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
@@ -760,6 +847,341 @@ async def process_yookassa_sbp_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_mulenpay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await message.answer("❌ Оплата через Mulen Pay временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = payment_result.get("payment_url")
|
||||
mulen_payment_id = payment_result.get("mulen_payment_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"MULENPAY_PAY_BUTTON",
|
||||
"💳 Оплатить через Mulen Pay",
|
||||
),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_mulenpay_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id
|
||||
|
||||
message_template = texts.t(
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Mulen Pay</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
payment_id=payment_id_display,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж для пользователя %s: %s₽, ID: %s",
|
||||
db_user.telegram_id,
|
||||
amount_rubles,
|
||||
payment_id_display,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания MulenPay платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_pal24_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
sbp_url = (
|
||||
payment_result.get("sbp_url")
|
||||
or payment_result.get("transfer_url")
|
||||
)
|
||||
card_url = payment_result.get("card_url")
|
||||
fallback_url = (
|
||||
payment_result.get("link_page_url")
|
||||
or payment_result.get("link_url")
|
||||
)
|
||||
|
||||
if not (sbp_url or card_url or fallback_url):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not sbp_url:
|
||||
sbp_url = fallback_url
|
||||
|
||||
bill_id = payment_result.get("bill_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
pay_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
steps: list[str] = []
|
||||
step_counter = 1
|
||||
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_url:
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_url and card_url != sbp_url:
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(card_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
if not pay_buttons and fallback_url:
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=fallback_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
follow_template = texts.t(
|
||||
"PAL24_INSTRUCTION_FOLLOW",
|
||||
"{step}. Следуйте подсказкам платёжной системы",
|
||||
)
|
||||
steps.append(follow_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
confirm_template = texts.t(
|
||||
"PAL24_INSTRUCTION_CONFIRM",
|
||||
"{step}. Подтвердите перевод",
|
||||
)
|
||||
steps.append(confirm_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
success_template = texts.t(
|
||||
"PAL24_INSTRUCTION_COMPLETE",
|
||||
"{step}. Средства зачислятся автоматически",
|
||||
)
|
||||
steps.append(success_template.format(step=step_counter))
|
||||
|
||||
message_template = texts.t(
|
||||
"PAL24_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"🏦 <b>Оплата через PayPalych</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID счета: {bill_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n{steps}\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard_rows = pay_buttons + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
bill_id=bill_id,
|
||||
steps="\n".join(steps),
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s",
|
||||
db_user.telegram_id,
|
||||
amount_kopeks / 100,
|
||||
bill_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -815,6 +1237,144 @@ async def check_yookassa_payment_status(
|
||||
logger.error(f"Ошибка проверки статуса платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_mulenpay_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"created": ("⏳", "Ожидает оплаты"),
|
||||
"processing": ("⌛", "Обрабатывается"),
|
||||
"success": ("✅", "Оплачен"),
|
||||
"canceled": ("❌", "Отменен"),
|
||||
"error": ("⚠️", "Ошибка"),
|
||||
"hold": ("🔒", "Холд"),
|
||||
"unknown": ("❓", "Неизвестно"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
message_lines = [
|
||||
"💳 Статус платежа Mulen Pay:\n\n",
|
||||
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
|
||||
f"📊 Статус: {emoji} {status_text}\n",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"created", "processing"}:
|
||||
message_lines.append(
|
||||
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
|
||||
)
|
||||
if payment.payment_url:
|
||||
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}")
|
||||
elif payment.status in {"canceled", "error"}:
|
||||
message_lines.append(
|
||||
f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
message_text = "".join(message_lines)
|
||||
|
||||
if len(message_text) > 190:
|
||||
await callback.message.answer(message_text)
|
||||
await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True)
|
||||
else:
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса MulenPay: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_pal24_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_pal24_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"NEW": ("⏳", "Ожидает оплаты"),
|
||||
"PROCESS": ("⌛", "Обрабатывается"),
|
||||
"SUCCESS": ("✅", "Оплачен"),
|
||||
"FAIL": ("❌", "Отменен"),
|
||||
"UNDERPAID": ("⚠️", "Недоплата"),
|
||||
"OVERPAID": ("⚠️", "Переплата"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
metadata = payment.metadata_json or {}
|
||||
links_meta = metadata.get("links") if isinstance(metadata, dict) else None
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
|
||||
message_lines = [
|
||||
"🏦 Статус платежа PayPalych:",
|
||||
"",
|
||||
f"🆔 ID счета: {payment.bill_id}",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
|
||||
f"📊 Статус: {emoji} {status_text}",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("")
|
||||
message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"NEW", "PROCESS"}:
|
||||
message_lines.append("")
|
||||
message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
|
||||
if sbp_link:
|
||||
message_lines.append("")
|
||||
message_lines.append(f"🏦 СБП: {sbp_link}")
|
||||
if card_link and card_link != sbp_link:
|
||||
message_lines.append(f"💳 Банковская карта: {card_link}")
|
||||
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
|
||||
message_lines.append("")
|
||||
message_lines.append(
|
||||
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.answer(
|
||||
"\n".join(message_lines),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_cryptobot_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -1085,6 +1645,18 @@ async def handle_quick_amount_selection(
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
@@ -1096,6 +1668,77 @@ async def handle_quick_amount_selection(
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_topup_amount_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
_, method, amount_str = callback.data.split("|", 2)
|
||||
amount_kopeks = int(amount_str)
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный запрос", show_alert=True)
|
||||
return
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
await callback.answer("❌ Некорректная сумма", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
await process_stars_payment_amount(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
elif method == "tribute":
|
||||
await start_tribute_payment(callback, db_user)
|
||||
return
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка быстрого пополнения: {error}")
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
@@ -1132,12 +1775,22 @@ def register_handlers(dp: Dispatcher):
|
||||
start_yookassa_sbp_payment,
|
||||
F.data == "topup_yookassa_sbp"
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
start_mulenpay_payment,
|
||||
F.data == "topup_mulenpay"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_pal24_payment,
|
||||
F.data == "topup_pal24"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
start_tribute_payment,
|
||||
F.data == "topup_tribute"
|
||||
@@ -1168,6 +1821,16 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_cryptobot_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_mulenpay_payment_status,
|
||||
F.data.startswith("check_mulenpay_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_pal24_payment_status,
|
||||
F.data.startswith("check_pal24_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
@@ -1178,3 +1841,8 @@ def register_handlers(dp: Dispatcher):
|
||||
handle_quick_amount_selection,
|
||||
F.data.startswith("quick_amount_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_topup_amount_callback,
|
||||
F.data.startswith("topup_amount|")
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -28,6 +29,26 @@ async def handle_unknown_callback(
|
||||
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
async def handle_noop(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_current_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_cancel(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -83,9 +104,28 @@ def register_handlers(dp: Dispatcher):
|
||||
show_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
# No-op utility handlers used in many keyboards
|
||||
dp.callback_query.register(
|
||||
handle_noop,
|
||||
F.data == "noop"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_current_page,
|
||||
F.data == "current_page"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_cancel,
|
||||
F.data.in_(["cancel", "subscription_cancel"])
|
||||
)
|
||||
|
||||
# Самый последний: ловим любые неизвестные текстовые сообщения
|
||||
# Исключаем специальные сервисные события (например, успешные платежи),
|
||||
# чтобы их обработка не прерывалась общим хендлером неизвестных сообщений
|
||||
dp.message.register(
|
||||
handle_unknown_message,
|
||||
StateFilter(None),
|
||||
F.successful_payment.is_(None)
|
||||
)
|
||||
|
||||
+131
-29
@@ -1,20 +1,22 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_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.crud.user_message import get_random_active_message
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,11 +24,12 @@ logger = logging.getLogger(__name__)
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
skip_callback_answer: bool = False,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from datetime import datetime
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
@@ -41,11 +44,18 @@ async def show_main_menu(
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
is_admin = settings.is_admin(db_user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
@@ -53,25 +63,15 @@ async def show_main_menu(
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user: User
|
||||
) -> None:
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
|
||||
if not skip_callback_answer:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
@@ -91,6 +91,90 @@ async def show_service_rules(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_language_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("LANGUAGE_PROMPT", "🌐 Выберите язык интерфейса:"),
|
||||
keyboard=get_language_selection_keyboard(
|
||||
current_language=db_user.language,
|
||||
include_back=True,
|
||||
language=db_user.language,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_language_change(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
selected_raw = (callback.data or "").split(":", 1)[-1]
|
||||
normalized_selected = selected_raw.strip().lower()
|
||||
|
||||
available_map = {
|
||||
lang.strip().lower(): lang.strip()
|
||||
for lang in settings.get_available_languages()
|
||||
if isinstance(lang, str) and lang.strip()
|
||||
}
|
||||
|
||||
if normalized_selected not in available_map:
|
||||
await callback.answer("❌ Unsupported language", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_language = available_map[normalized_selected].lower()
|
||||
|
||||
if db_user.language.lower() == normalized_selected:
|
||||
await show_main_menu(
|
||||
callback,
|
||||
db_user,
|
||||
db,
|
||||
skip_callback_answer=True,
|
||||
)
|
||||
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
|
||||
return
|
||||
|
||||
updated_user = await update_user(db, db_user, language=resolved_language)
|
||||
texts = get_texts(updated_user.language)
|
||||
|
||||
await show_main_menu(
|
||||
callback,
|
||||
updated_user,
|
||||
db,
|
||||
skip_callback_answer=True,
|
||||
)
|
||||
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
|
||||
|
||||
|
||||
async def handle_back_to_menu(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -112,11 +196,18 @@ async def handle_back_to_menu(
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
is_admin = settings.is_admin(db_user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
@@ -124,7 +215,7 @@ async def handle_back_to_menu(
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -234,3 +325,14 @@ def register_handlers(dp: Dispatcher):
|
||||
show_service_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_language_menu,
|
||||
F.data == "menu_language"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_change,
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(None)
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,6 +62,21 @@ async def process_promocode(
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
db_user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления об активации промокода %s: %s",
|
||||
code,
|
||||
notify_error,
|
||||
)
|
||||
else:
|
||||
error_messages = {
|
||||
"not_found": texts.PROMOCODE_INVALID,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_server_status_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.server_status_service import (
|
||||
ServerStatusEntry,
|
||||
ServerStatusError,
|
||||
ServerStatusService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_status_service = ServerStatusService()
|
||||
|
||||
|
||||
async def show_server_status(callback: types.CallbackQuery, db_user: User) -> None:
|
||||
await _render_server_status(callback, db_user, page=1)
|
||||
|
||||
|
||||
async def change_server_status_page(callback: types.CallbackQuery, db_user: User) -> None:
|
||||
try:
|
||||
_, page_str = callback.data.split(":", 1)
|
||||
page = int(page_str)
|
||||
except (ValueError, AttributeError, IndexError):
|
||||
page = 1
|
||||
|
||||
await _render_server_status(callback, db_user, page=page)
|
||||
|
||||
|
||||
async def _render_server_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
page: int = 1,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if settings.get_server_status_mode() != "xray":
|
||||
await callback.answer(texts.t("SERVER_STATUS_NOT_CONFIGURED", "Функция недоступна."), show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
servers = await _status_service.get_servers()
|
||||
except ServerStatusError as error:
|
||||
logger.warning("Server status error: %s", error)
|
||||
await callback.answer(
|
||||
texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error("Unexpected server status error: %s", error)
|
||||
await callback.answer(
|
||||
texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
message, total_pages, current_page = _build_status_message(servers, texts, page)
|
||||
keyboard = get_server_status_keyboard(db_user.language, current_page, total_pages)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _build_status_message(
|
||||
servers: List[ServerStatusEntry],
|
||||
texts,
|
||||
page: int,
|
||||
) -> Tuple[str, int, int]:
|
||||
total_servers = len(servers)
|
||||
online_servers = [server for server in servers if server.is_online]
|
||||
offline_servers = [server for server in servers if not server.is_online]
|
||||
|
||||
items_per_page = settings.get_server_status_items_per_page()
|
||||
pages = _split_into_pages(online_servers, offline_servers, items_per_page)
|
||||
|
||||
total_pages = max(1, len(pages))
|
||||
current_index = min(max(page - 1, 0), total_pages - 1)
|
||||
|
||||
current_online, current_offline = pages[current_index] if pages else ([], [])
|
||||
|
||||
lines: List[str] = [texts.t("SERVER_STATUS_TITLE", "📊 <b>Статус серверов</b>")]
|
||||
|
||||
if total_servers == 0:
|
||||
lines.append("")
|
||||
lines.append(texts.t("SERVER_STATUS_NO_SERVERS", "Нет данных о серверах."))
|
||||
message = "\n".join(lines).strip()
|
||||
return message, 1, 1
|
||||
|
||||
summary = texts.t(
|
||||
"SERVER_STATUS_SUMMARY",
|
||||
"Всего серверов: {total} (в сети: {online}, вне сети: {offline})",
|
||||
).format(
|
||||
total=total_servers,
|
||||
online=len(online_servers),
|
||||
offline=len(offline_servers),
|
||||
)
|
||||
|
||||
updated_at = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
summary,
|
||||
texts.t("SERVER_STATUS_UPDATED_AT", "⏱ Обновлено: {time}").format(time=updated_at),
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
if current_online:
|
||||
lines.append(texts.t("SERVER_STATUS_AVAILABLE", "✅ <b>Доступны</b>"))
|
||||
lines.extend(_format_server_lines(current_online, texts, online=True))
|
||||
lines.append("")
|
||||
|
||||
if current_offline:
|
||||
lines.append(texts.t("SERVER_STATUS_UNAVAILABLE", "❌ <b>Недоступны</b>"))
|
||||
lines.extend(_format_server_lines(current_offline, texts, online=False))
|
||||
lines.append("")
|
||||
|
||||
if total_pages > 1:
|
||||
lines.append(
|
||||
texts.t("SERVER_STATUS_PAGINATION", "Страница {current} из {total}").format(
|
||||
current=current_index + 1,
|
||||
total=total_pages,
|
||||
)
|
||||
)
|
||||
|
||||
message = "\n".join(line for line in lines if line is not None)
|
||||
message = message.strip()
|
||||
return message, total_pages, current_index + 1
|
||||
|
||||
|
||||
def _split_into_pages(
|
||||
online: List[ServerStatusEntry],
|
||||
offline: List[ServerStatusEntry],
|
||||
items_per_page: int,
|
||||
) -> List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]]:
|
||||
if not online and not offline:
|
||||
return [([], [])]
|
||||
|
||||
pages: List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]] = []
|
||||
online_index = 0
|
||||
offline_index = 0
|
||||
|
||||
while online_index < len(online) or offline_index < len(offline):
|
||||
current_online: List[ServerStatusEntry] = []
|
||||
current_offline: List[ServerStatusEntry] = []
|
||||
remaining = max(1, items_per_page)
|
||||
|
||||
while remaining > 0 and online_index < len(online):
|
||||
current_online.append(online[online_index])
|
||||
online_index += 1
|
||||
remaining -= 1
|
||||
|
||||
while remaining > 0 and offline_index < len(offline):
|
||||
current_offline.append(offline[offline_index])
|
||||
offline_index += 1
|
||||
remaining -= 1
|
||||
|
||||
pages.append((current_online, current_offline))
|
||||
|
||||
return pages if pages else [([], [])]
|
||||
|
||||
|
||||
def _format_server_lines(
|
||||
servers: List[ServerStatusEntry],
|
||||
texts,
|
||||
*,
|
||||
online: bool,
|
||||
) -> List[str]:
|
||||
lines: List[str] = []
|
||||
for server in servers:
|
||||
latency_text: str
|
||||
if online:
|
||||
if server.latency_ms and server.latency_ms > 0:
|
||||
latency_text = texts.t("SERVER_STATUS_LATENCY", "{latency} мс").format(
|
||||
latency=server.latency_ms
|
||||
)
|
||||
else:
|
||||
latency_text = texts.t("SERVER_STATUS_LATENCY_UNKNOWN", "нет данных")
|
||||
else:
|
||||
latency_text = texts.t("SERVER_STATUS_OFFLINE", "нет ответа")
|
||||
|
||||
name = server.display_name or server.name
|
||||
flag_prefix = f"{server.flag} " if server.flag else ""
|
||||
server_line = f"{flag_prefix}{name} — {latency_text}"
|
||||
lines.append(f"<blockquote>{server_line}</blockquote>")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_server_status,
|
||||
F.data == "menu_server_status",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
change_server_status_page,
|
||||
F.data.startswith("server_status_page:"),
|
||||
)
|
||||
|
||||
@@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
|
||||
+350
-68
@@ -17,14 +17,20 @@ from app.database.crud.campaign import (
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_by_id,
|
||||
)
|
||||
from app.database.models import UserStatus
|
||||
from app.database.models import UserStatus, SubscriptionStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
|
||||
get_rules_keyboard,
|
||||
get_main_menu_keyboard,
|
||||
get_post_registration_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
)
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
|
||||
@@ -71,8 +77,8 @@ async def _apply_campaign_bonus_if_needed(
|
||||
|
||||
|
||||
async def handle_potential_referral_code(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
@@ -132,7 +138,80 @@ async def handle_potential_referral_code(
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _get_language_prompt_text() -> str:
|
||||
return "🌐 Выберите язык / Choose your language:"
|
||||
|
||||
|
||||
async def _prompt_language_selection(message: types.Message, state: FSMContext) -> None:
|
||||
logger.info(f"🌐 LANGUAGE: Запрос выбора языка для пользователя {message.from_user.id}")
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_language)
|
||||
await message.answer(
|
||||
_get_language_prompt_text(),
|
||||
reply_markup=get_language_selection_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
async def _continue_registration_after_language(
|
||||
*,
|
||||
message: types.Message | None,
|
||||
callback: types.CallbackQuery | None,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
target_message = callback.message if callback else message
|
||||
if not target_message:
|
||||
logger.warning("⚠️ LANGUAGE: Нет доступного сообщения для продолжения регистрации")
|
||||
return
|
||||
|
||||
async def _complete_registration_wrapper():
|
||||
if callback:
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ LANGUAGE: SKIP_RULES_ACCEPT включен - пропускаем правила")
|
||||
|
||||
if data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ LANGUAGE: Реферер найден: {referrer.id}")
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
await _complete_registration_wrapper()
|
||||
else:
|
||||
try:
|
||||
await target_message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info("🔍 LANGUAGE: Ожидание ввода реферального кода")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде после выбора языка: {error}")
|
||||
await _complete_registration_wrapper()
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await target_message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 LANGUAGE: Правила отправлены после выбора языка")
|
||||
|
||||
|
||||
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
|
||||
@@ -164,6 +243,22 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
await state.update_data(referral_code=referral_code)
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if campaign:
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_campaign_link_visit_notification(
|
||||
db,
|
||||
message.from_user,
|
||||
campaign,
|
||||
user,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления о переходе по кампании %s: %s",
|
||||
campaign.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
|
||||
@@ -228,17 +323,23 @@ 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
|
||||
)
|
||||
|
||||
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,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -308,55 +409,128 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
else:
|
||||
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
logger.info(f"💾 Установлен язык по умолчанию: {language}")
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
|
||||
if data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
if not data.get('language'):
|
||||
if settings.is_language_selection_enabled():
|
||||
await _prompt_language_selection(message, state)
|
||||
return
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
await complete_registration(message, state, db)
|
||||
else:
|
||||
try:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
default_language = (
|
||||
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
|
||||
if isinstance(settings.DEFAULT_LANGUAGE, str)
|
||||
else DEFAULT_LANGUAGE
|
||||
)
|
||||
normalized_default = default_language.split("-")[0].lower()
|
||||
data['language'] = normalized_default
|
||||
await state.set_data(data)
|
||||
logger.info(
|
||||
"🌐 LANGUAGE: выбор языка отключен, устанавливаем язык по умолчанию '%s'",
|
||||
normalized_default,
|
||||
)
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=message,
|
||||
callback=None,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def process_language_selection(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
logger.info(
|
||||
f"🌐 LANGUAGE: Пользователь {callback.from_user.id} выбрал язык ({callback.data})"
|
||||
)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
data = await state.get_data() or {}
|
||||
default_language = (
|
||||
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
|
||||
if isinstance(settings.DEFAULT_LANGUAGE, str)
|
||||
else DEFAULT_LANGUAGE
|
||||
)
|
||||
normalized_default = default_language.split("-")[0].lower()
|
||||
data['language'] = normalized_default
|
||||
await state.set_data(data)
|
||||
|
||||
texts = get_texts(normalized_default)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info("🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration(message, state, db)
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=None,
|
||||
callback=callback,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"📋 Правила отправлены")
|
||||
selected_raw = (callback.data or "").split(":", 1)[-1]
|
||||
normalized_selected = selected_raw.strip().lower()
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Установлено состояние: {current_state}")
|
||||
available_map = {
|
||||
lang.strip().lower(): lang.strip()
|
||||
for lang in settings.get_available_languages()
|
||||
if isinstance(lang, str) and lang.strip()
|
||||
}
|
||||
|
||||
if normalized_selected not in available_map:
|
||||
logger.warning(
|
||||
f"⚠️ LANGUAGE: Выбран недоступный язык '{normalized_selected}' пользователем {callback.from_user.id}"
|
||||
)
|
||||
await callback.answer("❌ Unsupported language", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_language = available_map[normalized_selected].lower()
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = resolved_language
|
||||
await state.set_data(data)
|
||||
|
||||
texts = get_texts(resolved_language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка: {error}")
|
||||
await callback.message.answer(
|
||||
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=None,
|
||||
callback=callback,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def process_rules_accept(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
@@ -561,18 +735,25 @@ 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)
|
||||
)
|
||||
|
||||
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,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -591,7 +772,16 @@ async def complete_registration_from_callback(
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
campaign_id = data.get('campaign_id')
|
||||
is_new_user_registration = (
|
||||
existing_user is None
|
||||
or (
|
||||
existing_user
|
||||
and existing_user.status == UserStatus.DELETED.value
|
||||
)
|
||||
)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
@@ -689,7 +879,16 @@ async def complete_registration_from_callback(
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, callback.from_user)
|
||||
|
||||
if offer_text:
|
||||
skip_welcome_offer = bool(campaign_id) and is_new_user_registration
|
||||
|
||||
if skip_welcome_offer:
|
||||
logger.info(
|
||||
"ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s",
|
||||
user.telegram_id,
|
||||
campaign_id,
|
||||
)
|
||||
|
||||
if offer_text and not skip_welcome_offer:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
@@ -708,18 +907,25 @@ 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)
|
||||
)
|
||||
|
||||
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,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -767,18 +973,25 @@ 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)
|
||||
)
|
||||
|
||||
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,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -797,7 +1010,16 @@ async def complete_registration(
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
campaign_id = data.get('campaign_id')
|
||||
is_new_user_registration = (
|
||||
existing_user is None
|
||||
or (
|
||||
existing_user
|
||||
and existing_user.status == UserStatus.DELETED.value
|
||||
)
|
||||
)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
@@ -895,7 +1117,16 @@ async def complete_registration(
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, message.from_user)
|
||||
|
||||
if offer_text:
|
||||
skip_welcome_offer = bool(campaign_id) and is_new_user_registration
|
||||
|
||||
if skip_welcome_offer:
|
||||
logger.info(
|
||||
"ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s",
|
||||
user.telegram_id,
|
||||
campaign_id,
|
||||
)
|
||||
|
||||
if offer_text and not skip_welcome_offer:
|
||||
try:
|
||||
await message.answer(
|
||||
offer_text,
|
||||
@@ -914,18 +1145,25 @@ 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)
|
||||
)
|
||||
|
||||
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,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -1033,8 +1271,9 @@ def get_referral_code_keyboard(language: str):
|
||||
|
||||
async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
import html
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
user_name=html.escape(user.full_name or ""),
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
@@ -1052,8 +1291,9 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
|
||||
import html
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
user_name=html.escape(user_name or ""),
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
)
|
||||
|
||||
@@ -1105,6 +1345,34 @@ async def required_sub_channel_check(
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
if user and user.subscription:
|
||||
subscription = user.subscription
|
||||
if (
|
||||
subscription.is_trial
|
||||
and subscription.status == SubscriptionStatus.DISABLED.value
|
||||
):
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
logger.info(
|
||||
"✅ Триальная подписка пользователя %s восстановлена после подтверждения подписки на канал",
|
||||
user.telegram_id,
|
||||
)
|
||||
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if user.remnawave_uuid:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
"❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя %s: %s",
|
||||
user.telegram_id if user else query.from_user.id,
|
||||
api_error,
|
||||
)
|
||||
|
||||
await query.answer(
|
||||
texts.t("CHANNEL_SUBSCRIBE_THANKS", "✅ Спасибо за подписку"),
|
||||
show_alert=True,
|
||||
@@ -1124,14 +1392,21 @@ 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)
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
@@ -1226,7 +1501,14 @@ def register_handlers(dp: Dispatcher):
|
||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_selection,
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(RegistrationStates.waiting_for_language)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_language_selection")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_referral_code_skip,
|
||||
F.data == "referral_skip",
|
||||
|
||||
+3313
-1383
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_support_keyboard
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,10 +18,12 @@ async def show_support_info(
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SUPPORT_INFO,
|
||||
reply_markup=get_support_keyboard(db_user.language)
|
||||
support_info = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=support_info,
|
||||
keyboard=get_support_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1060
-308
File diff suppressed because it is too large
Load Diff
+770
-104
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,26 @@ WELCOME: |
|
||||
|
||||
MENU:
|
||||
BALANCE: "Balance"
|
||||
SERVER_STATUS: "Server status"
|
||||
SUBSCRIPTION: "Subscription"
|
||||
|
||||
SERVER_STATUS:
|
||||
TITLE: "📊 <b>Server status</b>"
|
||||
SUMMARY: "Total servers: {total} (online: {online}, offline: {offline})"
|
||||
AVAILABLE: "✅ <b>Online</b>"
|
||||
UNAVAILABLE: "❌ <b>Offline</b>"
|
||||
LATENCY: "{latency} ms"
|
||||
LATENCY_UNKNOWN: "no data"
|
||||
OFFLINE: "no response"
|
||||
NO_SERVERS: "No server data available."
|
||||
PAGINATION: "Page {current} of {total}"
|
||||
PREV_PAGE: "⬅️ Back"
|
||||
NEXT_PAGE: "Next ➡️"
|
||||
REFRESH: "🔄 Refresh"
|
||||
ERROR_SHORT: "Failed to fetch data"
|
||||
NOT_CONFIGURED: "Feature is not available."
|
||||
UPDATED_AT: "⏱ Updated at: {time}"
|
||||
|
||||
RULES_TEXT: |
|
||||
Remnawave service rules:
|
||||
1. Follow the law of your jurisdiction.
|
||||
|
||||
@@ -6,8 +6,26 @@ WELCOME: |
|
||||
|
||||
MENU:
|
||||
BALANCE: "Баланс"
|
||||
SERVER_STATUS: "Статус серверов"
|
||||
SUBSCRIPTION: "Подписка"
|
||||
|
||||
SERVER_STATUS:
|
||||
TITLE: "📊 <b>Статус серверов</b>"
|
||||
SUMMARY: "Всего серверов: {total} (в сети: {online}, вне сети: {offline})"
|
||||
AVAILABLE: "✅ <b>Доступны</b>"
|
||||
UNAVAILABLE: "❌ <b>Недоступны</b>"
|
||||
LATENCY: "{latency} мс"
|
||||
LATENCY_UNKNOWN: "нет данных"
|
||||
OFFLINE: "нет ответа"
|
||||
NO_SERVERS: "Нет данных о серверах."
|
||||
PAGINATION: "Страница {current} из {total}"
|
||||
PREV_PAGE: "⬅️ Назад"
|
||||
NEXT_PAGE: "Вперед ➡️"
|
||||
REFRESH: "🔄 Обновить"
|
||||
ERROR_SHORT: "Не удалось получить данные"
|
||||
NOT_CONFIGURED: "Функция недоступна."
|
||||
UPDATED_AT: "⏱ Обновлено: {time}"
|
||||
|
||||
RULES_TEXT: |
|
||||
Правила сервиса Remnawave:
|
||||
1. Следуйте законам своей страны.
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
{
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Server management is unavailable — only one server is accessible",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Manage subscription countries</b>\n\n📋 <b>Current countries ({current_count}):</b>\n{current_list}\n\n💡 <b>How it works:</b>\n✅ — currently connected\n➕ — will be added (paid)\n➖ — will be removed (free)\n⚪ — not selected\n\n⚠️ <b>Important:</b> Reconnecting removed countries will be charged again!",
|
||||
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
|
||||
"PAID_FEATURE_ONLY": "⚠ This feature is available only for paid subscriptions",
|
||||
"PAID_FEATURE_ONLY_SHORT": "⚠ Paid subscriptions only",
|
||||
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ This server is not available for your promo group",
|
||||
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ No changes detected",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
|
||||
"COUNTRY_CHANGES_ADDED_HEADER": "➕ <b>Added countries:</b>\n",
|
||||
"COUNTRY_CHANGES_CHARGED": "💰 Charged: {amount} (for {months} mo)",
|
||||
"COUNTRY_CHANGES_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Removed countries:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Reconnecting later will be charged",
|
||||
"COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 <b>Active countries:</b> {count}",
|
||||
"ADMIN_MAIN_MENU": "🏠 Main menu",
|
||||
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
|
||||
"AUTOPAY_BUTTON": "💳 Auto payment",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
|
||||
"AUTOPAY_STATUS_ENABLED": "enabled",
|
||||
"AUTOPAY_STATUS_DISABLED": "disabled",
|
||||
"AUTOPAY_MENU_TEXT": "💳 <b>Auto payment</b>\n\n📊 <b>Status:</b> {status}\n⏰ <b>Charge:</b> {days} days before expiry\n\nChoose an action:",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
|
||||
"AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Choose how many days before expiry to charge the payment:",
|
||||
"AUTOPAY_DAYS_SET": "✅ Set to {days} days!",
|
||||
"BACK": "⬅️ Back",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
|
||||
"CANCEL": "❌ Cancel",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
|
||||
"CHECK_STATUS_BUTTON": "📊 Check status",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
|
||||
"CONFIRM": "✅ Confirm",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
|
||||
"CONNECT_BUTTON": "🔗 Connect",
|
||||
"HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ",
|
||||
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Download Happ</b>\nChoose your device:",
|
||||
"HAPP_PLATFORM_IOS": "🍎 iOS",
|
||||
"HAPP_PLATFORM_ANDROID": "🤖 Android",
|
||||
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
|
||||
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
|
||||
"HAPP_PLATFORM_PC": "💻 PC",
|
||||
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:",
|
||||
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured",
|
||||
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link",
|
||||
"CONTINUE": "➡️ Continue",
|
||||
"CONTINUE_BUTTON": "➡️ Continue",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
|
||||
@@ -37,12 +69,34 @@
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
|
||||
"LANGUAGE_PROMPT": "🌐 Choose the interface language:",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"DEVICE_UUID_NOT_FOUND": "❌ User UUID not found",
|
||||
"DEVICE_NONE_CONNECTED": "ℹ️ You have no connected devices",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
|
||||
"DEVICE_MANAGEMENT_OVERVIEW": "🔄 <b>Device management</b>\n\n📊 Total connected: {total} devices\n📄 Page {page} of {pages}\n\n",
|
||||
"DEVICE_MANAGEMENT_CONNECTED_HEADER": "<b>Connected devices:</b>\n",
|
||||
"DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
|
||||
"DEVICE_MANAGEMENT_ACTIONS": "\n💡 <b>Actions:</b>\n• Select a device to reset\n• Or reset all devices at once",
|
||||
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
|
||||
"DEVICE_PAGE_LOAD_ERROR": "❌ Failed to open the page",
|
||||
"DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request",
|
||||
"DEVICE_RESET_PARSE_ERROR": "❌ Failed to process the request",
|
||||
"DEVICE_RESET_SUCCESS": "✅ Device {device} has been reset!",
|
||||
"DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset",
|
||||
"DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID",
|
||||
"DEVICE_RESET_NOT_FOUND": "❌ Device not found",
|
||||
"DEVICE_RESET_ERROR": "❌ Failed to reset the device",
|
||||
"DEVICE_LIST_FETCH_ERROR": "❌ Failed to load device list",
|
||||
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>All devices have been reset!</b>\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect",
|
||||
"DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ <b>Devices reset partially</b>\n\n✅ Removed: {success} devices\n❌ Failed to remove: {failed} devices\n\nTry again or contact support.",
|
||||
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Couldn't reset devices</b>\n\nPlease try again later or contact support.\n\nTotal devices: {total}",
|
||||
"MENU_BALANCE": "💰 Balance",
|
||||
"MENU_SUBSCRIPTION": "📱 Subscription",
|
||||
"MENU_TRIAL": "🎁 Trial subscription",
|
||||
@@ -85,6 +139,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!",
|
||||
@@ -123,6 +180,73 @@
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Rules",
|
||||
"ADMIN_STATISTICS": "📊 Statistics",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Promo group discounts:",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Servers: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Traffic: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Devices: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Add-on discounts: <b>enabled</b>",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Add-on discounts: <b>disabled</b>",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Add-on purchase discounts are <b>enabled</b>.",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Add-on purchase discounts are <b>disabled</b>.",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>User promo group</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.",
|
||||
"ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:",
|
||||
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”",
|
||||
"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.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_USERS": "👥 Users",
|
||||
"AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!",
|
||||
@@ -139,6 +263,12 @@
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Subscription setup</b>\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n",
|
||||
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Your promo group discounts</b>",
|
||||
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n",
|
||||
@@ -150,6 +280,19 @@
|
||||
"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",
|
||||
@@ -159,6 +302,7 @@
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
"MENU_SERVER_STATUS": "📊 Server status",
|
||||
"MENU_SUPPORT": "🛠️ Support",
|
||||
"OPERATION_CANCELLED": "❌ Operation cancelled",
|
||||
"PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
@@ -192,6 +336,21 @@
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Final configuration</b>\n\n📅 <b>Period:</b> {period} days\n📈 <b>Traffic:</b> {traffic}\n🌍 <b>Countries:</b> {countries}\n📱 <b>Devices:</b> {devices}\n\n💰 <b>Total:</b> {total_price}\n\nConfirm the purchase?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Trial subscription",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Technical support</b>\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n",
|
||||
"SERVER_STATUS_AVAILABLE": "✅ <b>Online</b>",
|
||||
"SERVER_STATUS_ERROR_SHORT": "Failed to fetch data",
|
||||
"SERVER_STATUS_LATENCY": "{latency} ms",
|
||||
"SERVER_STATUS_LATENCY_UNKNOWN": "no data",
|
||||
"SERVER_STATUS_NEXT_PAGE": "Next ➡️",
|
||||
"SERVER_STATUS_NO_SERVERS": "No server data available.",
|
||||
"SERVER_STATUS_NOT_CONFIGURED": "Feature is not available.",
|
||||
"SERVER_STATUS_OFFLINE": "no response",
|
||||
"SERVER_STATUS_PAGINATION": "Page {current} of {total}",
|
||||
"SERVER_STATUS_PREV_PAGE": "⬅️ Back",
|
||||
"SERVER_STATUS_REFRESH": "🔄 Refresh",
|
||||
"SERVER_STATUS_SUMMARY": "Total servers: {total} (online: {online}, offline: {offline})",
|
||||
"SERVER_STATUS_TITLE": "📊 <b>Server status</b>",
|
||||
"SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}",
|
||||
"SERVER_STATUS_UNAVAILABLE": "❌ <b>Offline</b>",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Confirm traffic change</b>\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Switch traffic limit</b>\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 <b>Important:</b>\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n",
|
||||
@@ -214,6 +373,9 @@
|
||||
"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_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",
|
||||
@@ -246,7 +408,9 @@
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ The subscription link is available via the buttons below or in the “My subscription” section.",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\nℹ️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
@@ -328,6 +492,7 @@
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
@@ -338,5 +503,200 @@
|
||||
"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_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",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
|
||||
"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\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}.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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": "📨 Рассылки",
|
||||
@@ -11,14 +25,89 @@
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Правила",
|
||||
"ADMIN_STATISTICS": "📊 Статистика",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Скидки промогруппы:",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Серверы: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Трафик: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Устройства: {percent}%",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Скидки на доп. услуги: <b>включены</b>",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Скидки на доп. услуги: <b>отключены</b>",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Скидки на докупку доп. услуг <b>включены</b>.",
|
||||
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Скидки на докупку доп. услуг <b>отключены</b>.",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>Промогруппа пользователя</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.",
|
||||
"ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:",
|
||||
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»",
|
||||
"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": "Это базовая группа.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_USERS": "👥 Пользователи",
|
||||
"ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:",
|
||||
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:",
|
||||
"AUTOPAY_BUTTON": "💳 Автоплатёж",
|
||||
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
|
||||
"AUTOPAY_STATUS_ENABLED": "включен",
|
||||
"AUTOPAY_STATUS_DISABLED": "выключен",
|
||||
"AUTOPAY_MENU_TEXT": "💳 <b>Автоплатеж</b>\n\n📊 <b>Статус:</b> {status}\n⏰ <b>Списание за:</b> {days} дн. до окончания\n\nВыберите действие:",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
|
||||
"AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Выберите за сколько дней до окончания списывать средства:",
|
||||
"AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
|
||||
"BALANCE_BUTTON": "💰 Баланс: {balance}",
|
||||
@@ -32,8 +121,15 @@
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Настройка подписки</b>\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n",
|
||||
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Скидки вашей промогруппы</b>",
|
||||
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:",
|
||||
"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 ",
|
||||
@@ -44,11 +140,22 @@
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
|
||||
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
|
||||
"CONFIRM": "✅ Подтвердить",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
|
||||
"CONNECT_BUTTON": "🔗 Подключиться",
|
||||
"HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ",
|
||||
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
|
||||
"HAPP_PLATFORM_IOS": "🍎 iOS",
|
||||
"HAPP_PLATFORM_ANDROID": "🤖 Android",
|
||||
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
|
||||
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
|
||||
"HAPP_PLATFORM_PC": "💻 ПК",
|
||||
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:",
|
||||
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена",
|
||||
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
|
||||
"CONTACT_SUPPORT": "💬 Написать в поддержку",
|
||||
"CONTINUE": "➡️ Продолжить",
|
||||
"CONTINUE_BUTTON": "✅ Продолжить",
|
||||
@@ -59,6 +166,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",
|
||||
@@ -73,7 +193,9 @@
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_PROMPT": "🌐 Выберите язык интерфейса:",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
|
||||
@@ -82,6 +204,26 @@
|
||||
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
|
||||
"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": "💎 Купить подписку",
|
||||
@@ -90,6 +232,7 @@
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
"MENU_RULES": "📋 Правила сервиса",
|
||||
"MENU_SERVER_STATUS": "📊 Статус серверов",
|
||||
"MENU_SUBSCRIPTION": "📱 Подписка",
|
||||
"MENU_SUPPORT": "🛠️ Техподдержка",
|
||||
"MENU_TRIAL": "🧪 Тестовая подписка",
|
||||
@@ -162,6 +305,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": "🧪 Тестовая подписка",
|
||||
@@ -177,6 +323,21 @@
|
||||
"SUCCESS": "✅ Успешно",
|
||||
"REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Техническая поддержка</b>\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n",
|
||||
"SERVER_STATUS_AVAILABLE": "✅ <b>Доступны</b>",
|
||||
"SERVER_STATUS_ERROR_SHORT": "Не удалось получить данные",
|
||||
"SERVER_STATUS_LATENCY": "{latency} мс",
|
||||
"SERVER_STATUS_LATENCY_UNKNOWN": "нет данных",
|
||||
"SERVER_STATUS_NEXT_PAGE": "Вперед ➡️",
|
||||
"SERVER_STATUS_NO_SERVERS": "Нет данных о серверах.",
|
||||
"SERVER_STATUS_NOT_CONFIGURED": "Функция недоступна.",
|
||||
"SERVER_STATUS_OFFLINE": "нет ответа",
|
||||
"SERVER_STATUS_PAGINATION": "Страница {current} из {total}",
|
||||
"SERVER_STATUS_PREV_PAGE": "⬅️ Назад",
|
||||
"SERVER_STATUS_REFRESH": "🔄 Обновить",
|
||||
"SERVER_STATUS_SUMMARY": "Всего серверов: {total} (в сети: {online}, вне сети: {offline})",
|
||||
"SERVER_STATUS_TITLE": "📊 <b>Статус серверов</b>",
|
||||
"SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}",
|
||||
"SERVER_STATUS_UNAVAILABLE": "❌ <b>Недоступны</b>",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Подтверждение переключения трафика</b>\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Переключение лимита трафика</b>\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n",
|
||||
@@ -210,6 +371,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": "❌ Пользователь не найден",
|
||||
@@ -246,7 +410,9 @@
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
@@ -328,6 +494,7 @@
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
@@ -338,5 +505,198 @@
|
||||
"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_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": "Скидка за продление подписки",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
|
||||
"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Нажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {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_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": "Сообщение удалено"
|
||||
}
|
||||
|
||||
+12
-18
@@ -44,15 +44,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"SUPPORT_INFO": (
|
||||
"\n🛠️ <b>Техническая поддержка</b>\n\n"
|
||||
"По всем вопросам обращайтесь к нашей поддержке:\n\n"
|
||||
f"👤 {settings.SUPPORT_USERNAME}\n\n"
|
||||
"Мы поможем с:\n"
|
||||
"• Настройкой подключения\n"
|
||||
"• Решением технических проблем \n"
|
||||
"• Вопросами по оплате\n"
|
||||
"• Другими вопросами\n\n"
|
||||
"⏰ Время ответа: обычно в течение 1-2 часов\n"
|
||||
"\n🛟 <b>Поддержка</b>\n\n"
|
||||
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
|
||||
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
|
||||
"• 📋 Мои тикеты — статус и переписка\n"
|
||||
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
|
||||
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -72,15 +69,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"SUPPORT_INFO": (
|
||||
"\n🛠️ <b>Technical support</b>\n\n"
|
||||
"For any questions contact our support:\n\n"
|
||||
f"👤 {settings.SUPPORT_USERNAME}\n\n"
|
||||
"We can help with:\n"
|
||||
"• Connection setup\n"
|
||||
"• Troubleshooting issues\n"
|
||||
"• Payment questions\n"
|
||||
"• Other requests\n\n"
|
||||
"⏰ Response time: usually within 1-2 hours\n"
|
||||
"\n🛟 <b>RemnaWave Support</b>\n\n"
|
||||
"This is the ticket center: create requests, view replies and history.\n\n"
|
||||
"• 🎫 Create ticket — describe your issue or question\n"
|
||||
"• 📋 My tickets — status and conversation\n"
|
||||
"• 💬 Contact — message directly if needed\n\n"
|
||||
"Prefer tickets — it helps us respond faster and keep context.\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
+13
-6
@@ -12,6 +12,7 @@ from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.states import RegistrationStates
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +24,7 @@ async def _refresh_remnawave_description(
|
||||
) -> None:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
await api.update_user(uuid=remnawave_uuid, description=description)
|
||||
logger.info(
|
||||
f"✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave"
|
||||
@@ -115,6 +116,7 @@ class AuthMiddleware(BaseMiddleware):
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_language.state,
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
@@ -125,7 +127,10 @@ class AuthMiddleware(BaseMiddleware):
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
and (
|
||||
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
|
||||
or event.data.startswith('language_select:')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -158,15 +163,17 @@ class AuthMiddleware(BaseMiddleware):
|
||||
logger.info(f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.first_name != user.first_name:
|
||||
safe_first = sanitize_telegram_name(user.first_name)
|
||||
safe_last = sanitize_telegram_name(user.last_name)
|
||||
if db_user.first_name != safe_first:
|
||||
old_first_name = db_user.first_name
|
||||
db_user.first_name = user.first_name
|
||||
db_user.first_name = safe_first
|
||||
logger.info(f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.last_name != user.last_name:
|
||||
if db_user.last_name != safe_last:
|
||||
old_last_name = db_user.last_name
|
||||
db_user.last_name = user.last_name
|
||||
db_user.last_name = safe_last
|
||||
logger.info(f"🔄 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
|
||||
@@ -7,10 +7,15 @@ from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,6 +54,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
# Админам разрешаем пропускать проверку подписки, чтобы не блокировать
|
||||
# работу панели управления даже при отсутствии подписки. Важно делать
|
||||
# это до обращения к состоянию, чтобы не выполнять лишние операции.
|
||||
if settings.is_admin(telegram_id):
|
||||
logger.debug(
|
||||
"✅ Пользователь %s является администратором — пропускаем проверку подписки",
|
||||
telegram_id,
|
||||
)
|
||||
return await handler(event, data)
|
||||
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
@@ -85,11 +100,14 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
elif member.status in self.BAD_MEMBER_STATUS:
|
||||
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
|
||||
|
||||
|
||||
if telegram_id:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
return
|
||||
|
||||
return
|
||||
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
else:
|
||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||
@@ -110,6 +128,53 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||
return await handler(event, data)
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
async for db in get_db():
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user or not user.subscription:
|
||||
logger.debug(
|
||||
"⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию",
|
||||
telegram_id,
|
||||
)
|
||||
break
|
||||
|
||||
subscription = user.subscription
|
||||
if (not subscription.is_trial or
|
||||
subscription.status != SubscriptionStatus.ACTIVE.value):
|
||||
logger.debug(
|
||||
"ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
|
||||
telegram_id,
|
||||
subscription.is_trial,
|
||||
subscription.status,
|
||||
)
|
||||
break
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info(
|
||||
"🚫 Триальная подписка пользователя %s отключена после отписки от канала",
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
if user.remnawave_uuid:
|
||||
service = SubscriptionService()
|
||||
try:
|
||||
await service.disable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
"❌ Не удалось отключить пользователя RemnaWave %s: %s",
|
||||
user.remnawave_uuid,
|
||||
api_error,
|
||||
)
|
||||
except Exception as db_error:
|
||||
logger.error(
|
||||
"❌ Ошибка деактивации подписки пользователя %s после отписки: %s",
|
||||
telegram_id,
|
||||
db_error,
|
||||
)
|
||||
finally:
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
|
||||
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
|
||||
|
||||
@@ -4,6 +4,7 @@ import time
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,13 +34,31 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
if now - last_call < self.rate_limit:
|
||||
logger.warning(f"🚫 Throttling для пользователя {user_id}")
|
||||
|
||||
|
||||
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
|
||||
if isinstance(event, Message):
|
||||
try:
|
||||
fsm: FSMContext = data.get("state") # может отсутствовать
|
||||
current = await fsm.get_state() if fsm else None
|
||||
except Exception:
|
||||
current = None
|
||||
is_ticket_state = False
|
||||
if current:
|
||||
# Молчим только в состояниях работы с тикетами (user/admin): waiting_for_message / waiting_for_reply
|
||||
lowered = str(current)
|
||||
is_ticket_state = (
|
||||
(":waiting_for_message" in lowered or ":waiting_for_reply" in lowered) and
|
||||
("TicketStates" in lowered or "AdminTicketStates" in lowered)
|
||||
)
|
||||
if is_ticket_state:
|
||||
return
|
||||
# В остальных случаях — явный блок
|
||||
await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!")
|
||||
return
|
||||
# Для callback допустим краткое уведомление
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True)
|
||||
|
||||
return
|
||||
return
|
||||
|
||||
self.user_buckets[user_id] = now
|
||||
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram import Bot, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, Subscription, Transaction
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
PromoCodeType,
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,25 +28,147 @@ class AdminNotificationService:
|
||||
self.bot = bot
|
||||
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
|
||||
try:
|
||||
referrer = await get_user_by_id(db, referred_by_id)
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
else:
|
||||
return f"ID {referrer.telegram_id}"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
|
||||
return f"ID {referred_by_id}"
|
||||
|
||||
async def _get_user_promo_group(self, db: AsyncSession, user: User) -> Optional[PromoGroup]:
|
||||
if getattr(user, "promo_group", None):
|
||||
return user.promo_group
|
||||
|
||||
if not user.promo_group_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
await db.refresh(user, attribute_names=["promo_group"])
|
||||
except Exception:
|
||||
# relationship might not be available — fallback to direct fetch
|
||||
pass
|
||||
|
||||
if getattr(user, "promo_group", None):
|
||||
return user.promo_group
|
||||
|
||||
try:
|
||||
return await get_promo_group_by_id(db, user.promo_group_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка загрузки промогруппы %s пользователя %s: %s",
|
||||
user.promo_group_id,
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
||||
discount_lines: List[str] = []
|
||||
|
||||
discount_map = {
|
||||
"servers": ("Серверы", promo_group.server_discount_percent),
|
||||
"traffic": ("Трафик", promo_group.traffic_discount_percent),
|
||||
"devices": ("Устройства", promo_group.device_discount_percent),
|
||||
}
|
||||
|
||||
for _, (title, percent) in discount_map.items():
|
||||
if percent and percent > 0:
|
||||
discount_lines.append(f"• {title}: -{percent}%")
|
||||
|
||||
period_discounts_raw = promo_group.period_discounts or {}
|
||||
period_items: List[tuple[int, int]] = []
|
||||
|
||||
if isinstance(period_discounts_raw, dict):
|
||||
for raw_days, raw_percent in period_discounts_raw.items():
|
||||
try:
|
||||
days = int(raw_days)
|
||||
percent = int(raw_percent)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if percent > 0:
|
||||
period_items.append((days, percent))
|
||||
|
||||
period_items.sort(key=lambda item: item[0])
|
||||
|
||||
if period_items:
|
||||
formatted_periods = ", ".join(
|
||||
f"{days} д. — -{percent}%" for days, percent in period_items
|
||||
)
|
||||
discount_lines.append(f"• Периоды: {formatted_periods}")
|
||||
|
||||
if promo_group.apply_discounts_to_addons:
|
||||
discount_lines.append("• Доп. услуги: ✅ скидка действует")
|
||||
else:
|
||||
discount_lines.append("• Доп. услуги: ❌ без скидки")
|
||||
|
||||
return discount_lines
|
||||
|
||||
def _format_promo_group_block(
|
||||
self,
|
||||
promo_group: Optional[PromoGroup],
|
||||
*,
|
||||
title: str = "Промогруппа",
|
||||
icon: str = "🏷️",
|
||||
) -> str:
|
||||
if not promo_group:
|
||||
return f"{icon} <b>{title}:</b> —"
|
||||
|
||||
lines = [f"{icon} <b>{title}:</b> {promo_group.name}"]
|
||||
|
||||
discount_lines = self._format_promo_group_discounts(promo_group)
|
||||
if discount_lines:
|
||||
lines.append("💸 <b>Скидки:</b>")
|
||||
lines.extend(discount_lines)
|
||||
else:
|
||||
lines.append("💸 <b>Скидки:</b> отсутствуют")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_promocode_type_display(self, promo_type: Optional[str]) -> str:
|
||||
mapping = {
|
||||
PromoCodeType.BALANCE.value: "💰 Бонус на баланс",
|
||||
PromoCodeType.SUBSCRIPTION_DAYS.value: "⏰ Доп. дни подписки",
|
||||
PromoCodeType.TRIAL_SUBSCRIPTION.value: "🎁 Триал подписка",
|
||||
}
|
||||
|
||||
if not promo_type:
|
||||
return "ℹ️ Не указан"
|
||||
|
||||
return mapping.get(promo_type, f"ℹ️ {promo_type}")
|
||||
|
||||
def _format_campaign_bonus(self, campaign: AdvertisingCampaign) -> List[str]:
|
||||
if campaign.is_balance_bonus:
|
||||
return [
|
||||
f"💰 Баланс: {settings.format_price(campaign.balance_bonus_kopeks or 0)}",
|
||||
]
|
||||
|
||||
if campaign.is_subscription_bonus:
|
||||
default_devices = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1)
|
||||
details = [
|
||||
f"📅 Дней подписки: {campaign.subscription_duration_days or 0}",
|
||||
f"📊 Трафик: {campaign.subscription_traffic_gb or 0} ГБ",
|
||||
f"📱 Устройства: {campaign.subscription_device_limit or default_devices}",
|
||||
]
|
||||
if campaign.subscription_squads:
|
||||
details.append(f"🌐 Сквады: {len(campaign.subscription_squads)} шт.")
|
||||
return details
|
||||
|
||||
return ["ℹ️ Бонусы не предусмотрены"]
|
||||
|
||||
async def send_trial_activation_notification(
|
||||
self,
|
||||
@@ -50,7 +182,9 @@ class AdminNotificationService:
|
||||
try:
|
||||
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -58,6 +192,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
@@ -100,7 +236,9 @@ class AdminNotificationService:
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -108,6 +246,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -212,13 +352,30 @@ class AdminNotificationService:
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение"
|
||||
deposit_count_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Transaction)
|
||||
.where(
|
||||
Transaction.user_id == user.id,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True)
|
||||
)
|
||||
)
|
||||
deposit_count = deposit_count_result.scalar_one() or 0
|
||||
topup_status = "🆕 Первое пополнение" if deposit_count <= 1 else "🔄 Пополнение"
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
subscription_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = subscription_result.scalar_one_or_none()
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -226,6 +383,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Детали пополнения:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -237,7 +396,7 @@ class AdminNotificationService:
|
||||
➕ Изменение: +{settings.format_price(balance_change)}
|
||||
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
@@ -254,21 +413,31 @@ class AdminNotificationService:
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
extended_days: int,
|
||||
old_end_date: datetime
|
||||
old_end_date: datetime,
|
||||
*,
|
||||
new_end_date: datetime | None = None,
|
||||
balance_after: int | None = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
current_end_date = new_end_date or subscription.end_date
|
||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -277,24 +446,207 @@ class AdminNotificationService:
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
return False
|
||||
|
||||
async def _send_message(self, text: str) -> bool:
|
||||
|
||||
async def send_promocode_activation_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
promocode_data: Dict[str, Any],
|
||||
effect_description: str,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
type_display = self._get_promocode_type_display(promocode_data.get("type"))
|
||||
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
|
||||
|
||||
message_lines = [
|
||||
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🎟️ <b>Промокод:</b>",
|
||||
f"🔖 Код: <code>{promocode_data.get('code')}</code>",
|
||||
f"🧾 Тип: {type_display}",
|
||||
f"📊 Использования: {usage_info}",
|
||||
]
|
||||
|
||||
balance_bonus = promocode_data.get("balance_bonus_kopeks", 0)
|
||||
if balance_bonus:
|
||||
message_lines.append(
|
||||
f"💰 Бонус на баланс: {settings.format_price(balance_bonus)}"
|
||||
)
|
||||
|
||||
subscription_days = promocode_data.get("subscription_days", 0)
|
||||
if subscription_days:
|
||||
message_lines.append(f"📅 Доп. дни подписки: {subscription_days}")
|
||||
|
||||
valid_until = promocode_data.get("valid_until")
|
||||
if valid_until:
|
||||
message_lines.append(
|
||||
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
|
||||
if isinstance(valid_until, datetime)
|
||||
else f"⏳ Действует до: {valid_until}"
|
||||
)
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
"📝 <b>Эффект:</b>",
|
||||
effect_description.strip() or "✅ Промокод активирован",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об активации промокода: {e}")
|
||||
return False
|
||||
|
||||
async def send_campaign_link_visit_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
telegram_user: types.User,
|
||||
campaign: AdvertisingCampaign,
|
||||
user: Optional[User] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
user_status = "🆕 Новый пользователь" if not user else "👥 Уже зарегистрирован"
|
||||
promo_block = (
|
||||
self._format_promo_group_block(await self._get_user_promo_group(db, user))
|
||||
if user
|
||||
else self._format_promo_group_block(None)
|
||||
)
|
||||
|
||||
full_name = telegram_user.full_name or telegram_user.username or str(telegram_user.id)
|
||||
username = f"@{telegram_user.username}" if telegram_user.username else "отсутствует"
|
||||
|
||||
message_lines = [
|
||||
"📣 <b>ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ</b>",
|
||||
"",
|
||||
f"🧾 <b>Кампания:</b> {campaign.name}",
|
||||
f"🆔 ID кампании: {campaign.id}",
|
||||
f"🔗 Start-параметр: <code>{campaign.start_parameter}</code>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{telegram_user.id}</code>",
|
||||
f"📱 <b>Username:</b> {username}",
|
||||
user_status,
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🎯 <b>Бонус кампании:</b>",
|
||||
]
|
||||
|
||||
bonus_lines = self._format_campaign_bonus(campaign)
|
||||
message_lines.extend(bonus_lines)
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о переходе по кампании: {e}")
|
||||
return False
|
||||
|
||||
async def send_user_promo_group_change_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
old_group: Optional[PromoGroup],
|
||||
new_group: PromoGroup,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
initiator: Optional[User] = None,
|
||||
automatic: bool = False,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
title = "🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ" if automatic else "👥 СМЕНА ПРОМОГРУППЫ"
|
||||
initiator_line = None
|
||||
if initiator:
|
||||
initiator_line = (
|
||||
f"👮 <b>Инициатор:</b> {initiator.full_name} (ID: {initiator.telegram_id})"
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = "🤖 Автоматическое назначение"
|
||||
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
|
||||
]
|
||||
|
||||
if old_group and old_group.id != new_group.id:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
self._format_promo_group_block(
|
||||
old_group, title="Предыдущая промогруппа", icon="♻️"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if initiator_line:
|
||||
message_lines.extend(["", initiator_line])
|
||||
|
||||
if reason:
|
||||
message_lines.extend(["", f"📝 Причина: {reason}"])
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о смене промогруппы: {e}")
|
||||
return False
|
||||
|
||||
async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
|
||||
return False
|
||||
@@ -307,8 +659,16 @@ class AdminNotificationService:
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
|
||||
if self.topic_id:
|
||||
message_kwargs['message_thread_id'] = self.topic_id
|
||||
# route to ticket-specific topic if provided
|
||||
thread_id = None
|
||||
if ticket_event and self.ticket_topic_id:
|
||||
thread_id = self.ticket_topic_id
|
||||
elif self.topic_id:
|
||||
thread_id = self.topic_id
|
||||
if thread_id:
|
||||
message_kwargs['message_thread_id'] = thread_id
|
||||
if reply_markup is not None:
|
||||
message_kwargs['reply_markup'] = reply_markup
|
||||
|
||||
await self.bot.send_message(**message_kwargs)
|
||||
logger.info(f"Уведомление отправлено в чат {self.chat_id}")
|
||||
@@ -332,6 +692,8 @@ class AdminNotificationService:
|
||||
'telegram_stars': '⭐ Telegram Stars',
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': '💳 Mulen Pay (карта)',
|
||||
'pal24': '🏦 PayPalych (СБП)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
}
|
||||
@@ -339,22 +701,21 @@ class AdminNotificationService:
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
|
||||
return method_names.get(payment_method, f'💰 С баланса')
|
||||
return method_names.get(payment_method, '💰 С баланса')
|
||||
|
||||
def _format_traffic(self, traffic_gb: int) -> str:
|
||||
if traffic_gb == 0:
|
||||
return "∞ Безлимит"
|
||||
return f"{traffic_gb} ГБ"
|
||||
|
||||
def _get_subscription_status(self, user: User) -> str:
|
||||
if not user.subscription:
|
||||
def _get_subscription_status(self, subscription: Optional[Subscription]) -> str:
|
||||
if not subscription:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
sub = user.subscription
|
||||
if sub.is_trial:
|
||||
return f"🎯 Триал (до {sub.end_date.strftime('%d.%m')})"
|
||||
elif sub.is_active:
|
||||
return f"✅ Активна (до {sub.end_date.strftime('%d.%m')})"
|
||||
|
||||
if subscription.is_trial:
|
||||
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
|
||||
elif subscription.is_active:
|
||||
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
@@ -387,40 +748,32 @@ class AdminNotificationService:
|
||||
if details.get("auto_enabled", False):
|
||||
icon = "⚠️"
|
||||
title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "warning"
|
||||
else:
|
||||
icon = "🔧"
|
||||
title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "info"
|
||||
|
||||
elif event_type == "disable":
|
||||
icon = "✅"
|
||||
title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "success"
|
||||
|
||||
elif event_type == "api_status":
|
||||
if status == "online":
|
||||
icon = "🟢"
|
||||
title = "API REMNAWAVE ВОССТАНОВЛЕНО"
|
||||
alert_type = "success"
|
||||
else:
|
||||
icon = "🔴"
|
||||
title = "API REMNAWAVE НЕДОСТУПНО"
|
||||
alert_type = "error"
|
||||
|
||||
elif event_type == "monitoring":
|
||||
if status == "started":
|
||||
icon = "🔍"
|
||||
title = "МОНИТОРИНГ ЗАПУЩЕН"
|
||||
alert_type = "info"
|
||||
else:
|
||||
icon = "⏹️"
|
||||
title = "МОНИТОРИНГ ОСТАНОВЛЕН"
|
||||
alert_type = "info"
|
||||
else:
|
||||
icon = "ℹ️"
|
||||
title = "СИСТЕМА ТЕХРАБОТ"
|
||||
alert_type = "info"
|
||||
|
||||
message_parts = [f"{icon} <b>{title}</b>", ""]
|
||||
|
||||
@@ -610,103 +963,6 @@ class AdminNotificationService:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_remnawave_panel_status_notification(
|
||||
self,
|
||||
status: str,
|
||||
details: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
details = details or {}
|
||||
|
||||
status_config = {
|
||||
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
|
||||
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
|
||||
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
|
||||
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
|
||||
}
|
||||
|
||||
config = status_config.get(status, status_config["offline"])
|
||||
|
||||
message_parts = [
|
||||
f"{config['icon']} <b>{config['title']}</b>",
|
||||
""
|
||||
]
|
||||
|
||||
if details.get("api_url"):
|
||||
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
|
||||
|
||||
if details.get("response_time"):
|
||||
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
|
||||
|
||||
if details.get("last_check"):
|
||||
last_check = details["last_check"]
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
|
||||
|
||||
if details.get("users_online"):
|
||||
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("✅ Все системы работают нормально.")
|
||||
|
||||
elif status == "offline":
|
||||
if details.get("error"):
|
||||
error_msg = str(details["error"])[:150]
|
||||
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
|
||||
|
||||
if details.get("consecutive_failures"):
|
||||
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
|
||||
|
||||
elif status == "degraded":
|
||||
if details.get("issues"):
|
||||
issues = details["issues"]
|
||||
if isinstance(issues, list):
|
||||
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
|
||||
for issue in issues[:3]:
|
||||
message_parts.append(f" • {issue}")
|
||||
else:
|
||||
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель работает, но возможны задержки или сбои.")
|
||||
|
||||
elif status == "maintenance":
|
||||
if details.get("maintenance_reason"):
|
||||
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
|
||||
|
||||
if details.get("estimated_duration"):
|
||||
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
|
||||
|
||||
if details.get("manual_message"):
|
||||
message_parts.append(f"💬 <b>Сообщение:</b> {details['manual_message']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_update_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -722,50 +978,64 @@ class AdminNotificationService:
|
||||
|
||||
try:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
|
||||
}
|
||||
|
||||
|
||||
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
|
||||
|
||||
message = f"""{title}
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
🔧 <b>Изменение:</b>
|
||||
📋 Параметр: {param_name}"""
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🔧 <b>Изменение:</b>",
|
||||
f"📋 Параметр: {param_name}",
|
||||
]
|
||||
|
||||
if update_type == "servers":
|
||||
old_servers_info = await self._format_servers_detailed(old_value)
|
||||
new_servers_info = await self._format_servers_detailed(new_value)
|
||||
|
||||
message += f"""
|
||||
📉 Было: {old_servers_info}
|
||||
📈 Стало: {new_servers_info}"""
|
||||
message_lines.extend(
|
||||
[
|
||||
f"📉 Было: {old_servers_info}",
|
||||
f"📈 Стало: {new_servers_info}",
|
||||
]
|
||||
)
|
||||
else:
|
||||
message += f"""
|
||||
📉 Было: {self._format_update_value(old_value, update_type)}
|
||||
📈 Стало: {self._format_update_value(new_value, update_type)}"""
|
||||
message_lines.extend(
|
||||
[
|
||||
f"📉 Было: {self._format_update_value(old_value, update_type)}",
|
||||
f"📈 Стало: {self._format_update_value(new_value, update_type)}",
|
||||
]
|
||||
)
|
||||
|
||||
if price_paid > 0:
|
||||
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
|
||||
message_lines.append(f"💰 Доплачено: {settings.format_price(price_paid)}")
|
||||
else:
|
||||
message += f"\n💸 Бесплатно"
|
||||
message_lines.append("💸 Бесплатно")
|
||||
|
||||
message += f"""
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
|
||||
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
|
||||
f"🔗 <b>Рефер:</b> {referrer_info}",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Рефер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
|
||||
@@ -801,3 +1071,21 @@ class AdminNotificationService:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
async def send_ticket_event_notification(
|
||||
self,
|
||||
text: str,
|
||||
keyboard: types.InlineKeyboardMarkup | None = None
|
||||
) -> bool:
|
||||
"""Публичный метод для отправки уведомлений по тикетам в админ-топик.
|
||||
Учитывает настройки включенности в settings.
|
||||
"""
|
||||
# Respect runtime toggle for admin ticket notifications
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
runtime_enabled = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
except Exception:
|
||||
runtime_enabled = True
|
||||
if not (self._is_enabled() and runtime_enabled):
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
|
||||
+295
-73
@@ -12,6 +12,7 @@ import aiofiles
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
@@ -21,7 +22,10 @@ from app.database.models import (
|
||||
ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog,
|
||||
SubscriptionConversion, SentNotification, BroadcastHistory,
|
||||
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
|
||||
CryptoBotPayment, WelcomeText, Base
|
||||
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
|
||||
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
|
||||
server_squad_promo_groups
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class BackupMetadata:
|
||||
timestamp: str
|
||||
version: str = "1.1"
|
||||
version: str = "1.2"
|
||||
database_type: str = "postgresql"
|
||||
backup_type: str = "full"
|
||||
tables_count: int = 0
|
||||
@@ -61,32 +65,43 @@ class BackupService:
|
||||
self._settings = self._load_settings()
|
||||
|
||||
self.backup_models_ordered = [
|
||||
ServiceRule,
|
||||
SystemSetting,
|
||||
ServiceRule,
|
||||
Squad,
|
||||
PromoCode,
|
||||
ServerSquad,
|
||||
|
||||
User,
|
||||
|
||||
WelcomeText,
|
||||
PromoGroup,
|
||||
User,
|
||||
PromoCode,
|
||||
WelcomeText,
|
||||
UserMessage,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionConversion,
|
||||
Transaction,
|
||||
YooKassaPayment,
|
||||
CryptoBotPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SubscriptionConversion,
|
||||
SentNotification,
|
||||
DiscountOffer,
|
||||
BroadcastHistory,
|
||||
UserMessage,
|
||||
|
||||
SentNotification,
|
||||
SubscriptionServer,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
SupportAuditLog,
|
||||
WebApiToken,
|
||||
]
|
||||
|
||||
|
||||
if self._settings.include_logs:
|
||||
self.backup_models_ordered.append(MonitoringLog)
|
||||
|
||||
self.association_tables = {
|
||||
"server_squad_promo_groups": server_squad_promo_groups,
|
||||
}
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
return BackupSettings(
|
||||
auto_backup_enabled=os.getenv("BACKUP_AUTO_ENABLED", "true").lower() == "true",
|
||||
@@ -164,6 +179,7 @@ class BackupService:
|
||||
models_to_backup.append(MonitoringLog)
|
||||
|
||||
backup_data = {}
|
||||
association_data = {}
|
||||
total_records = 0
|
||||
|
||||
async for db in get_db():
|
||||
@@ -207,7 +223,11 @@ class BackupService:
|
||||
total_records += len(table_data)
|
||||
|
||||
logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
|
||||
|
||||
|
||||
association_data = await self._export_association_tables(db)
|
||||
for records in association_data.values():
|
||||
total_records += len(records)
|
||||
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте данных: {e}")
|
||||
@@ -219,7 +239,7 @@ class BackupService:
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
database_type="postgresql" if settings.is_postgresql() else "sqlite",
|
||||
backup_type="full",
|
||||
tables_count=len(models_to_backup),
|
||||
tables_count=len(models_to_backup) + len(association_data),
|
||||
total_records=total_records,
|
||||
compressed=compress,
|
||||
created_by=created_by,
|
||||
@@ -232,10 +252,17 @@ class BackupService:
|
||||
filename += ".gz"
|
||||
|
||||
backup_path = self.backup_dir / filename
|
||||
|
||||
|
||||
file_snapshots = await self._collect_file_snapshots()
|
||||
|
||||
backup_structure = {
|
||||
"metadata": asdict(metadata),
|
||||
"data": backup_data
|
||||
"data": backup_data,
|
||||
"associations": association_data,
|
||||
"files": file_snapshots,
|
||||
"config": {
|
||||
"backup_settings": asdict(self._settings)
|
||||
}
|
||||
}
|
||||
|
||||
if compress:
|
||||
@@ -264,7 +291,7 @@ class BackupService:
|
||||
size_mb = file_size / 1024 / 1024
|
||||
message = (f"✅ Бекап успешно создан!\n"
|
||||
f"📁 Файл: {filename}\n"
|
||||
f"📊 Таблиц: {len(models_to_backup)}\n"
|
||||
f"📊 Таблиц: {metadata.tables_count}\n"
|
||||
f"📈 Записей: {total_records:,}\n"
|
||||
f"💾 Размер: {size_mb:.2f} MB")
|
||||
|
||||
@@ -312,6 +339,8 @@ class BackupService:
|
||||
|
||||
metadata = backup_structure.get("metadata", {})
|
||||
backup_data = backup_structure.get("data", {})
|
||||
association_data = backup_structure.get("associations", {})
|
||||
file_snapshots = backup_structure.get("files", {})
|
||||
|
||||
if not backup_data:
|
||||
return False, "❌ Файл бекапа не содержит данных"
|
||||
@@ -329,60 +358,55 @@ class BackupService:
|
||||
await self._clear_database_tables(db)
|
||||
|
||||
models_by_table = {model.__tablename__: model for model in self.backup_models_ordered}
|
||||
|
||||
await self._restore_users_without_referrals(db, backup_data, models_by_table)
|
||||
|
||||
for model in self.backup_models_ordered:
|
||||
table_name = model.__tablename__
|
||||
|
||||
if table_name == "users":
|
||||
|
||||
pre_restore_tables = {"promo_groups"}
|
||||
for table_name in pre_restore_tables:
|
||||
model = models_by_table.get(table_name)
|
||||
if not model:
|
||||
continue
|
||||
|
||||
|
||||
records = backup_data.get(table_name, [])
|
||||
if not records:
|
||||
continue
|
||||
|
||||
|
||||
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
|
||||
|
||||
for record_data in records:
|
||||
try:
|
||||
processed_data = self._process_record_data(record_data, model, table_name)
|
||||
|
||||
primary_key_col = self._get_primary_key_column(model)
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
existing_record = await db.execute(
|
||||
select(model).where(
|
||||
getattr(model, primary_key_col) == processed_data[primary_key_col]
|
||||
)
|
||||
)
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing and not clear_existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col:
|
||||
setattr(existing, key, value)
|
||||
logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}")
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
|
||||
restored_records += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
|
||||
logger.error(f"Проблемные данные: {record_data}")
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
restored = await self._restore_table_records(db, model, table_name, records, clear_existing)
|
||||
restored_records += restored
|
||||
|
||||
if restored:
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
await self._restore_users_without_referrals(db, backup_data, models_by_table)
|
||||
|
||||
for model in self.backup_models_ordered:
|
||||
table_name = model.__tablename__
|
||||
|
||||
if table_name == "users" or table_name in pre_restore_tables:
|
||||
continue
|
||||
|
||||
records = backup_data.get(table_name, [])
|
||||
if not records:
|
||||
continue
|
||||
|
||||
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
|
||||
restored = await self._restore_table_records(db, model, table_name, records, clear_existing)
|
||||
restored_records += restored
|
||||
|
||||
if restored:
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
await self._update_user_referrals(db, backup_data)
|
||||
|
||||
|
||||
assoc_tables, assoc_records = await self._restore_association_tables(
|
||||
db,
|
||||
association_data,
|
||||
clear_existing
|
||||
)
|
||||
restored_tables += assoc_tables
|
||||
restored_records += assoc_records
|
||||
|
||||
await db.commit()
|
||||
|
||||
break
|
||||
@@ -403,6 +427,11 @@ class BackupService:
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("restore_success", message)
|
||||
|
||||
if file_snapshots:
|
||||
restored_files = await self._restore_file_snapshots(file_snapshots)
|
||||
if restored_files:
|
||||
logger.info(f"📁 Восстановлено файлов конфигурации: {restored_files}")
|
||||
|
||||
return True, message
|
||||
|
||||
@@ -549,14 +578,156 @@ class BackupService:
|
||||
return col.name
|
||||
return None
|
||||
|
||||
async def _export_association_tables(self, db: AsyncSession) -> Dict[str, List[Dict[str, Any]]]:
|
||||
association_data: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
for table_name, table_obj in self.association_tables.items():
|
||||
try:
|
||||
logger.info(f"📊 Экспортируем таблицу связей: {table_name}")
|
||||
result = await db.execute(select(table_obj))
|
||||
rows = result.mappings().all()
|
||||
association_data[table_name] = [dict(row) for row in rows]
|
||||
logger.info(
|
||||
f"✅ Экспортировано {len(rows)} связей из {table_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка экспорта таблицы связей {table_name}: {e}")
|
||||
|
||||
return association_data
|
||||
|
||||
async def _restore_association_tables(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
association_data: Dict[str, List[Dict[str, Any]]],
|
||||
clear_existing: bool
|
||||
) -> Tuple[int, int]:
|
||||
if not association_data:
|
||||
return 0, 0
|
||||
|
||||
restored_tables = 0
|
||||
restored_records = 0
|
||||
|
||||
if "server_squad_promo_groups" in association_data:
|
||||
restored = await self._restore_server_squad_promo_groups(
|
||||
db,
|
||||
association_data["server_squad_promo_groups"],
|
||||
clear_existing
|
||||
)
|
||||
restored_tables += 1
|
||||
restored_records += restored
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
async def _restore_server_squad_promo_groups(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
records: List[Dict[str, Any]],
|
||||
clear_existing: bool
|
||||
) -> int:
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
if clear_existing:
|
||||
await db.execute(server_squad_promo_groups.delete())
|
||||
|
||||
restored = 0
|
||||
|
||||
for record in records:
|
||||
server_id = record.get("server_squad_id")
|
||||
promo_id = record.get("promo_group_id")
|
||||
|
||||
if server_id is None or promo_id is None:
|
||||
logger.warning(
|
||||
"Пропущена некорректная запись server_squad_promo_groups: %s",
|
||||
record
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
server_squad_promo_groups.insert().values(
|
||||
server_squad_id=server_id,
|
||||
promo_group_id=promo_id
|
||||
)
|
||||
)
|
||||
restored += 1
|
||||
except IntegrityError:
|
||||
logger.debug(
|
||||
"Запись server_squad_promo_groups (%s, %s) уже существует",
|
||||
server_id,
|
||||
promo_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s",
|
||||
server_id,
|
||||
promo_id,
|
||||
e
|
||||
)
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
return restored
|
||||
|
||||
async def _restore_table_records(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
model,
|
||||
table_name: str,
|
||||
records: List[Dict[str, Any]],
|
||||
clear_existing: bool
|
||||
) -> int:
|
||||
restored_count = 0
|
||||
|
||||
for record_data in records:
|
||||
try:
|
||||
processed_data = self._process_record_data(record_data, model, table_name)
|
||||
|
||||
primary_key_col = self._get_primary_key_column(model)
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
existing_record = await db.execute(
|
||||
select(model).where(
|
||||
getattr(model, primary_key_col) == processed_data[primary_key_col]
|
||||
)
|
||||
)
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing and not clear_existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col:
|
||||
setattr(existing, key, value)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
|
||||
restored_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
|
||||
logger.error(f"Проблемные данные: {record_data}")
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
return restored_count
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession):
|
||||
tables_order = [
|
||||
"subscription_servers", "sent_notifications",
|
||||
"user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses", "transactions",
|
||||
"yookassa_payments", "cryptobot_payments", "welcome_texts",
|
||||
"subscriptions", "users", "promocodes", "server_squads",
|
||||
"squads", "service_rules", "system_settings", "monitoring_logs"
|
||||
"server_squad_promo_groups",
|
||||
"ticket_messages", "tickets", "support_audit_logs",
|
||||
"advertising_campaign_registrations", "advertising_campaigns",
|
||||
"subscription_servers", "sent_notifications",
|
||||
"discount_offers", "user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses",
|
||||
"yookassa_payments", "cryptobot_payments",
|
||||
"mulenpay_payments", "pal24_payments",
|
||||
"transactions", "welcome_texts", "subscriptions",
|
||||
"promocodes", "users", "promo_groups",
|
||||
"server_squads", "squads", "service_rules",
|
||||
"system_settings", "web_api_tokens", "monitoring_logs"
|
||||
]
|
||||
|
||||
for table_name in tables_order:
|
||||
@@ -566,6 +737,57 @@ class BackupService:
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {e}")
|
||||
|
||||
async def _collect_file_snapshots(self) -> Dict[str, Dict[str, Any]]:
|
||||
snapshots: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
app_config_path = settings.get_app_config_path()
|
||||
if app_config_path:
|
||||
path_obj = Path(app_config_path)
|
||||
if path_obj.exists() and path_obj.is_file():
|
||||
try:
|
||||
async with aiofiles.open(path_obj, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
snapshots["app_config"] = {
|
||||
"path": str(path_obj),
|
||||
"content": content,
|
||||
"modified_at": datetime.fromtimestamp(
|
||||
path_obj.stat().st_mtime
|
||||
).isoformat()
|
||||
}
|
||||
logger.info(
|
||||
"📁 Добавлен в бекап файл конфигурации: %s",
|
||||
path_obj
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка чтения файла конфигурации %s: %s",
|
||||
path_obj,
|
||||
e
|
||||
)
|
||||
|
||||
return snapshots
|
||||
|
||||
async def _restore_file_snapshots(self, file_snapshots: Dict[str, Dict[str, Any]]) -> int:
|
||||
restored_files = 0
|
||||
|
||||
if not file_snapshots:
|
||||
return restored_files
|
||||
|
||||
app_config_snapshot = file_snapshots.get("app_config")
|
||||
if app_config_snapshot:
|
||||
target_path = Path(settings.get_app_config_path())
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
async with aiofiles.open(target_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(app_config_snapshot.get("content", ""))
|
||||
restored_files += 1
|
||||
logger.info("📁 Файл app-config восстановлен по пути %s", target_path)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка восстановления файла %s: %s", target_path, e)
|
||||
|
||||
return restored_files
|
||||
|
||||
async def get_backup_list(self) -> List[Dict[str, Any]]:
|
||||
backups = []
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import BroadcastHistory
|
||||
from app.handlers.admin.messages import (
|
||||
create_broadcast_keyboard,
|
||||
get_custom_users,
|
||||
get_target_users,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_MEDIA_TYPES = {"photo", "video", "document"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BroadcastMediaConfig:
|
||||
type: str
|
||||
file_id: str
|
||||
caption: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BroadcastConfig:
|
||||
target: str
|
||||
message_text: str
|
||||
selected_buttons: list[str]
|
||||
media: Optional[BroadcastMediaConfig] = None
|
||||
initiator_name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _BroadcastTask:
|
||||
task: asyncio.Task
|
||||
cancel_event: asyncio.Event
|
||||
|
||||
|
||||
class BroadcastService:
|
||||
"""Handles broadcast execution triggered from the admin web API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._bot: Optional[Bot] = None
|
||||
self._tasks: dict[int, _BroadcastTask] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self._bot = bot
|
||||
|
||||
def is_running(self, broadcast_id: int) -> bool:
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
return bool(task_entry and not task_entry.task.done())
|
||||
|
||||
async def start_broadcast(self, broadcast_id: int, config: BroadcastConfig) -> None:
|
||||
if self._bot is None:
|
||||
logger.error("Невозможно запустить рассылку %s: бот не инициализирован", broadcast_id)
|
||||
await self._mark_failed(broadcast_id)
|
||||
return
|
||||
|
||||
cancel_event = asyncio.Event()
|
||||
|
||||
async with self._lock:
|
||||
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
|
||||
logger.warning("Рассылка %s уже запущена", broadcast_id)
|
||||
return
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._run_broadcast(broadcast_id, config, cancel_event),
|
||||
name=f"broadcast-{broadcast_id}",
|
||||
)
|
||||
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
|
||||
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
|
||||
|
||||
async def request_stop(self, broadcast_id: int) -> bool:
|
||||
async with self._lock:
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
if not task_entry:
|
||||
return False
|
||||
|
||||
task_entry.cancel_event.set()
|
||||
return True
|
||||
|
||||
async def _run_broadcast(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
config: BroadcastConfig,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> None:
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error("Запись рассылки %s не найдена в БД", broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.status = "in_progress"
|
||||
broadcast.sent_count = 0
|
||||
broadcast.failed_count = 0
|
||||
await session.commit()
|
||||
|
||||
recipients = await self._fetch_recipients(config.target)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error("Запись рассылки %s удалена до запуска", broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.total_count = len(recipients)
|
||||
await session.commit()
|
||||
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
if not recipients:
|
||||
logger.info("Рассылка %s: получатели не найдены", broadcast_id)
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
return
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
|
||||
for index, user in enumerate(recipients, start=1):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
sent_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
if index % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Критическая ошибка при выполнении рассылки %s: %s", broadcast_id, exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count)
|
||||
|
||||
async def _fetch_recipients(self, target: str):
|
||||
async with AsyncSessionLocal() as session:
|
||||
if target.startswith("custom_"):
|
||||
criteria = target[len("custom_"):]
|
||||
return await get_custom_users(session, criteria)
|
||||
return await get_target_users(session, target)
|
||||
|
||||
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
|
||||
if selected_buttons is None:
|
||||
selected_buttons = []
|
||||
return create_broadcast_keyboard(selected_buttons)
|
||||
|
||||
async def _deliver_message(
|
||||
self,
|
||||
telegram_id: int,
|
||||
config: BroadcastConfig,
|
||||
keyboard: Optional[InlineKeyboardMarkup],
|
||||
) -> None:
|
||||
if not self._bot:
|
||||
raise RuntimeError("Телеграм-бот не инициализирован")
|
||||
|
||||
if config.media and config.media.type in VALID_MEDIA_TYPES:
|
||||
caption = config.media.caption or config.message_text
|
||||
if config.media.type == "photo":
|
||||
await self._bot.send_photo(
|
||||
chat_id=telegram_id,
|
||||
photo=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == "video":
|
||||
await self._bot.send_video(
|
||||
chat_id=telegram_id,
|
||||
video=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == "document":
|
||||
await self._bot.send_document(
|
||||
chat_id=telegram_id,
|
||||
document=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
|
||||
await self._bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=config.message_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
async def _mark_finished(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
*,
|
||||
cancelled: bool,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count
|
||||
broadcast.status = "cancelled" if cancelled else (
|
||||
"completed" if failed_count == 0 else "partial"
|
||||
)
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _mark_cancelled(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
) -> None:
|
||||
await self._mark_finished(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled=True,
|
||||
)
|
||||
|
||||
async def _mark_failed(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int = 0,
|
||||
failed_count: int = 0,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count or broadcast.failed_count
|
||||
broadcast.status = "failed"
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
|
||||
broadcast_service = BroadcastService()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MulenPayService:
|
||||
"""Интеграция с Mulen Pay API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.api_key = settings.MULENPAY_API_KEY
|
||||
self.shop_id = settings.MULENPAY_SHOP_ID
|
||||
self.secret_key = settings.MULENPAY_SECRET_KEY
|
||||
self.base_url = settings.MULENPAY_BASE_URL.rstrip("/")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(
|
||||
settings.is_mulenpay_enabled()
|
||||
and self.api_key
|
||||
and self.shop_id
|
||||
and self.secret_key
|
||||
)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("MulenPay service is not configured")
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"MulenPay API error %s %s: %s", response.status, endpoint, data
|
||||
)
|
||||
return None
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("MulenPay API request error: %s", error)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount_kopeks: int) -> str:
|
||||
return f"{amount_kopeks / 100:.2f}"
|
||||
|
||||
def _build_signature(self, currency: str, amount_str: str) -> str:
|
||||
raw_string = f"{currency}{amount_str}{self.shop_id}{self.secret_key}".encode()
|
||||
return hashlib.sha1(raw_string).hexdigest()
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
uuid: str,
|
||||
items: list,
|
||||
language: str = "ru",
|
||||
subscribe: Optional[str] = None,
|
||||
hold_time: Optional[int] = None,
|
||||
website_url: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("MulenPay service is not configured")
|
||||
return None
|
||||
|
||||
amount_str = self._format_amount(amount_kopeks)
|
||||
currency = "rub"
|
||||
payload = {
|
||||
"currency": currency,
|
||||
"amount": amount_str,
|
||||
"uuid": uuid,
|
||||
"shopId": self.shop_id,
|
||||
"description": description,
|
||||
"items": items,
|
||||
"language": language,
|
||||
"sign": self._build_signature(currency, amount_str),
|
||||
}
|
||||
|
||||
if subscribe:
|
||||
payload["subscribe"] = subscribe
|
||||
if hold_time is not None:
|
||||
payload["holdTime"] = hold_time
|
||||
if website_url:
|
||||
payload["website_url"] = website_url
|
||||
|
||||
response = await self._request("POST", "/v2/payments", json_data=payload)
|
||||
if not response or not response.get("success"):
|
||||
logger.error("Failed to create MulenPay payment: %s", response)
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("GET", f"/v2/payments/{payment_id}")
|
||||
@@ -0,0 +1,257 @@
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationSettingsService:
|
||||
"""Runtime-editable notification settings stored on disk."""
|
||||
|
||||
_storage_path: Path = Path("data/notification_settings.json")
|
||||
_data: Dict[str, Dict[str, Any]] = {}
|
||||
_loaded: bool = False
|
||||
|
||||
_DEFAULTS: Dict[str, Dict[str, Any]] = {
|
||||
"trial_inactive_1h": {"enabled": True},
|
||||
"trial_inactive_24h": {"enabled": True},
|
||||
"trial_channel_unsubscribed": {"enabled": True},
|
||||
"expired_1d": {"enabled": True},
|
||||
"expired_second_wave": {
|
||||
"enabled": True,
|
||||
"discount_percent": 10,
|
||||
"valid_hours": 24,
|
||||
},
|
||||
"expired_third_wave": {
|
||||
"enabled": True,
|
||||
"discount_percent": 20,
|
||||
"valid_hours": 24,
|
||||
"trigger_days": 5,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _ensure_dir(cls) -> None:
|
||||
try:
|
||||
cls._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc: # pragma: no cover - filesystem guard
|
||||
logger.error("Failed to create notification settings dir: %s", exc)
|
||||
|
||||
@classmethod
|
||||
def _load(cls) -> None:
|
||||
if cls._loaded:
|
||||
return
|
||||
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
if cls._storage_path.exists():
|
||||
raw = cls._storage_path.read_text(encoding="utf-8")
|
||||
cls._data = json.loads(raw) if raw.strip() else {}
|
||||
else:
|
||||
cls._data = {}
|
||||
except Exception as exc:
|
||||
logger.error("Failed to load notification settings: %s", exc)
|
||||
cls._data = {}
|
||||
|
||||
changed = cls._apply_defaults()
|
||||
if changed:
|
||||
cls._save()
|
||||
cls._loaded = True
|
||||
|
||||
@classmethod
|
||||
def _apply_defaults(cls) -> bool:
|
||||
changed = False
|
||||
for key, defaults in cls._DEFAULTS.items():
|
||||
current = cls._data.get(key)
|
||||
if not isinstance(current, dict):
|
||||
cls._data[key] = deepcopy(defaults)
|
||||
changed = True
|
||||
continue
|
||||
|
||||
for def_key, def_value in defaults.items():
|
||||
if def_key not in current:
|
||||
current[def_key] = def_value
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
@classmethod
|
||||
def _save(cls) -> bool:
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
cls._storage_path.write_text(
|
||||
json.dumps(cls._data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to save notification settings: %s", exc)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _get(cls, key: str) -> Dict[str, Any]:
|
||||
cls._load()
|
||||
value = cls._data.get(key)
|
||||
if not isinstance(value, dict):
|
||||
value = deepcopy(cls._DEFAULTS.get(key, {}))
|
||||
cls._data[key] = value
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, Dict[str, Any]]:
|
||||
cls._load()
|
||||
return deepcopy(cls._data)
|
||||
|
||||
@classmethod
|
||||
def _set_field(cls, key: str, field: str, value: Any) -> bool:
|
||||
cls._load()
|
||||
section = cls._get(key)
|
||||
section[field] = value
|
||||
cls._data[key] = section
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def set_enabled(cls, key: str, enabled: bool) -> bool:
|
||||
return cls._set_field(key, "enabled", bool(enabled))
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls, key: str) -> bool:
|
||||
return bool(cls._get(key).get("enabled", True))
|
||||
|
||||
# Trial inactivity helpers
|
||||
@classmethod
|
||||
def is_trial_inactive_1h_enabled(cls) -> bool:
|
||||
return cls.is_enabled("trial_inactive_1h")
|
||||
|
||||
@classmethod
|
||||
def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("trial_inactive_1h", enabled)
|
||||
|
||||
@classmethod
|
||||
def is_trial_inactive_24h_enabled(cls) -> bool:
|
||||
return cls.is_enabled("trial_inactive_24h")
|
||||
|
||||
@classmethod
|
||||
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("trial_inactive_24h", enabled)
|
||||
|
||||
@classmethod
|
||||
def is_trial_channel_unsubscribed_enabled(cls) -> bool:
|
||||
return cls.is_enabled("trial_channel_unsubscribed")
|
||||
|
||||
@classmethod
|
||||
def set_trial_channel_unsubscribed_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("trial_channel_unsubscribed", enabled)
|
||||
|
||||
# Expired subscription notifications
|
||||
@classmethod
|
||||
def is_expired_1d_enabled(cls) -> bool:
|
||||
return cls.is_enabled("expired_1d")
|
||||
|
||||
@classmethod
|
||||
def set_expired_1d_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("expired_1d", enabled)
|
||||
|
||||
@classmethod
|
||||
def is_second_wave_enabled(cls) -> bool:
|
||||
return cls.is_enabled("expired_second_wave")
|
||||
|
||||
@classmethod
|
||||
def set_second_wave_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("expired_second_wave", enabled)
|
||||
|
||||
@classmethod
|
||||
def get_second_wave_discount_percent(cls) -> int:
|
||||
value = cls._get("expired_second_wave").get("discount_percent", 10)
|
||||
try:
|
||||
return max(0, min(100, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 10
|
||||
|
||||
@classmethod
|
||||
def set_second_wave_discount_percent(cls, percent: int) -> bool:
|
||||
try:
|
||||
percent_int = max(0, min(100, int(percent)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_second_wave", "discount_percent", percent_int)
|
||||
|
||||
@classmethod
|
||||
def get_second_wave_valid_hours(cls) -> int:
|
||||
value = cls._get("expired_second_wave").get("valid_hours", 24)
|
||||
try:
|
||||
return max(1, min(168, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 24
|
||||
|
||||
@classmethod
|
||||
def set_second_wave_valid_hours(cls, hours: int) -> bool:
|
||||
try:
|
||||
hours_int = max(1, min(168, int(hours)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_second_wave", "valid_hours", hours_int)
|
||||
|
||||
@classmethod
|
||||
def is_third_wave_enabled(cls) -> bool:
|
||||
return cls.is_enabled("expired_third_wave")
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("expired_third_wave", enabled)
|
||||
|
||||
@classmethod
|
||||
def get_third_wave_discount_percent(cls) -> int:
|
||||
value = cls._get("expired_third_wave").get("discount_percent", 20)
|
||||
try:
|
||||
return max(0, min(100, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 20
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_discount_percent(cls, percent: int) -> bool:
|
||||
try:
|
||||
percent_int = max(0, min(100, int(percent)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_third_wave", "discount_percent", percent_int)
|
||||
|
||||
@classmethod
|
||||
def get_third_wave_valid_hours(cls) -> int:
|
||||
value = cls._get("expired_third_wave").get("valid_hours", 24)
|
||||
try:
|
||||
return max(1, min(168, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 24
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_valid_hours(cls, hours: int) -> bool:
|
||||
try:
|
||||
hours_int = max(1, min(168, int(hours)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_third_wave", "valid_hours", hours_int)
|
||||
|
||||
@classmethod
|
||||
def get_third_wave_trigger_days(cls) -> int:
|
||||
value = cls._get("expired_third_wave").get("trigger_days", 5)
|
||||
try:
|
||||
return max(2, min(60, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return 5
|
||||
|
||||
@classmethod
|
||||
def set_third_wave_trigger_days(cls, days: int) -> bool:
|
||||
try:
|
||||
days_int = max(2, min(60, int(days)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return cls._set_field("expired_third_wave", "trigger_days", days_int)
|
||||
|
||||
@classmethod
|
||||
def are_notifications_globally_enabled(cls) -> bool:
|
||||
return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True))
|
||||
@@ -0,0 +1,119 @@
|
||||
"""High level integration with PayPalych API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.external.pal24_client import Pal24Client, Pal24APIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pal24Service:
|
||||
"""Wrapper around :class:`Pal24Client` providing domain helpers."""
|
||||
|
||||
BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"}
|
||||
BILL_FAILED_STATES = {"FAIL", "CANCELLED"}
|
||||
BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"}
|
||||
|
||||
def __init__(self, client: Optional[Pal24Client] = None) -> None:
|
||||
self.client = client or Pal24Client()
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self.client.is_configured and settings.is_pal24_enabled()
|
||||
|
||||
async def create_bill(
|
||||
self,
|
||||
*,
|
||||
amount_kopeks: int,
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
description: str,
|
||||
ttl_seconds: Optional[int] = None,
|
||||
custom_payload: Optional[Dict[str, Any]] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not self.is_configured:
|
||||
raise Pal24APIError("Pal24 service is not configured")
|
||||
|
||||
amount_decimal = Pal24Client.normalize_amount(amount_kopeks)
|
||||
extra_payload: Dict[str, Any] = {
|
||||
"custom": custom_payload or {},
|
||||
"ttl": ttl_seconds,
|
||||
}
|
||||
|
||||
if payer_email:
|
||||
extra_payload["payer_email"] = payer_email
|
||||
if payment_method:
|
||||
extra_payload["payment_method"] = payment_method
|
||||
|
||||
filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})}
|
||||
|
||||
logger.info(
|
||||
"Создаем Pal24 счет: user_id=%s, order_id=%s, amount=%s, ttl=%s",
|
||||
user_id,
|
||||
order_id,
|
||||
amount_decimal,
|
||||
ttl_seconds,
|
||||
)
|
||||
|
||||
response = await self.client.create_bill(
|
||||
amount=amount_decimal,
|
||||
shop_id=settings.PAL24_SHOP_ID,
|
||||
order_id=order_id,
|
||||
description=description,
|
||||
type_="normal",
|
||||
**filtered_payload,
|
||||
)
|
||||
|
||||
logger.info("Pal24 счет создан: %s", response)
|
||||
return response
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
|
||||
logger.debug("Запрашиваем статус Pal24 счета %s", bill_id)
|
||||
return await self.client.get_bill_status(bill_id)
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
|
||||
logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id)
|
||||
return await self.client.get_payment_status(payment_id)
|
||||
|
||||
@staticmethod
|
||||
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
|
||||
missing = [field for field in required_fields if field not in payload]
|
||||
if missing:
|
||||
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
|
||||
|
||||
inv_id = str(payload["InvId"])
|
||||
out_sum = str(payload["OutSum"])
|
||||
signature = str(payload["SignatureValue"])
|
||||
|
||||
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
|
||||
raise Pal24APIError("Pal24 postback signature mismatch")
|
||||
|
||||
logger.info(
|
||||
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
|
||||
inv_id,
|
||||
payload.get("Status"),
|
||||
payload.get("TrsId"),
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def convert_to_kopeks(amount: str) -> int:
|
||||
decimal_amount = Decimal(str(amount))
|
||||
return int((decimal_amount * Decimal("100")).quantize(Decimal("1")))
|
||||
|
||||
@staticmethod
|
||||
def get_expiration(ttl_seconds: Optional[int]) -> Optional[datetime]:
|
||||
if not ttl_seconds:
|
||||
return None
|
||||
return datetime.utcnow() + timedelta(seconds=ttl_seconds)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
@@ -26,6 +28,24 @@ from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.database.crud.mulenpay import (
|
||||
create_mulenpay_payment,
|
||||
get_mulenpay_payment_by_local_id,
|
||||
get_mulenpay_payment_by_uuid,
|
||||
get_mulenpay_payment_by_mulen_id,
|
||||
update_mulenpay_payment_status,
|
||||
link_mulenpay_payment_to_transaction,
|
||||
)
|
||||
from app.database.crud.pal24 import (
|
||||
create_pal24_payment,
|
||||
get_pal24_payment_by_bill_id,
|
||||
get_pal24_payment_by_id,
|
||||
get_pal24_payment_by_order_id,
|
||||
link_pal24_payment_to_transaction,
|
||||
update_pal24_payment_status,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,6 +57,8 @@ class PaymentService:
|
||||
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
|
||||
self.stars_service = TelegramStarsService(bot) if bot else None
|
||||
self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None
|
||||
self.mulenpay_service = MulenPayService() if settings.is_mulenpay_enabled() else None
|
||||
self.pal24_service = Pal24Service() if settings.is_pal24_enabled() else None
|
||||
|
||||
async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language if user else "ru")
|
||||
@@ -633,7 +655,7 @@ class PaymentService:
|
||||
description: str = "Пополнение баланса",
|
||||
payload: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
if not self.cryptobot_service:
|
||||
logger.error("CryptoBot сервис не инициализирован")
|
||||
return None
|
||||
@@ -686,7 +708,709 @@ class PaymentService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_mulenpay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.mulenpay_service:
|
||||
logger.error("MulenPay сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма MulenPay больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payment_uuid = f"mulen_{user_id}_{uuid.uuid4().hex}"
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
items = [
|
||||
{
|
||||
"description": description[:128],
|
||||
"quantity": 1,
|
||||
"price": round(amount_rubles, 2),
|
||||
"vat_code": settings.MULENPAY_VAT_CODE,
|
||||
"payment_subject": settings.MULENPAY_PAYMENT_SUBJECT,
|
||||
"payment_mode": settings.MULENPAY_PAYMENT_MODE,
|
||||
}
|
||||
]
|
||||
|
||||
response = await self.mulenpay_service.create_payment(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
uuid=payment_uuid,
|
||||
items=items,
|
||||
language=language or settings.MULENPAY_LANGUAGE,
|
||||
website_url=settings.WEBHOOK_URL,
|
||||
)
|
||||
|
||||
if not response:
|
||||
logger.error("Ошибка создания MulenPay платежа")
|
||||
return None
|
||||
|
||||
mulen_payment_id = response.get("id")
|
||||
payment_url = response.get("paymentUrl")
|
||||
|
||||
metadata = {
|
||||
"user_id": user_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
local_payment = await create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
uuid=payment_uuid,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
mulen_payment_id=mulen_payment_id,
|
||||
currency="RUB",
|
||||
status="created",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан MulenPay платеж %s на %s₽ для пользователя %s",
|
||||
mulen_payment_id,
|
||||
amount_rubles,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"mulen_payment_id": mulen_payment_id,
|
||||
"payment_url": payment_url,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"uuid": payment_uuid,
|
||||
"status": "created",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания MulenPay платежа: {e}")
|
||||
return None
|
||||
|
||||
async def create_pal24_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
language: str,
|
||||
ttl_seconds: Optional[int] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.pal24_service or not self.pal24_service.is_configured:
|
||||
logger.error("Pal24 сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Pal24 меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Pal24 больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.PAL24_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
order_id = f"pal24_{user_id}_{uuid.uuid4().hex}"
|
||||
|
||||
custom_payload = {
|
||||
"user_id": user_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.pal24_service.create_bill(
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
description=description,
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
payment_method="SBP",
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
return None
|
||||
|
||||
if not response.get("success", True):
|
||||
logger.error("Pal24 вернул ошибку при создании счета: %s", response)
|
||||
return None
|
||||
|
||||
bill_id = response.get("bill_id")
|
||||
if not bill_id:
|
||||
logger.error("Pal24 не вернул bill_id: %s", response)
|
||||
return None
|
||||
|
||||
def _pick_url(*keys: str) -> Optional[str]:
|
||||
for key in keys:
|
||||
value = response.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
transfer_url = _pick_url(
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_url = _pick_url(
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
link_page_url = _pick_url(
|
||||
"link_page_url",
|
||||
"linkPageUrl",
|
||||
"page_url",
|
||||
"pageUrl",
|
||||
)
|
||||
|
||||
primary_link = transfer_url or link_page_url or card_url
|
||||
secondary_link = link_page_url or card_url or transfer_url
|
||||
|
||||
metadata_links = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"sbp": transfer_url,
|
||||
"card": card_url,
|
||||
"page": link_page_url,
|
||||
}.items()
|
||||
if value
|
||||
}
|
||||
|
||||
payment = await create_pal24_payment(
|
||||
db,
|
||||
user_id=user_id,
|
||||
bill_id=bill_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
status=response.get("status", "NEW"),
|
||||
type_=response.get("type", "normal"),
|
||||
currency=response.get("currency", "RUB"),
|
||||
link_url=primary_link,
|
||||
link_page_url=secondary_link,
|
||||
ttl=ttl_seconds,
|
||||
metadata={
|
||||
"raw_response": response,
|
||||
"language": language,
|
||||
**({"links": metadata_links} if metadata_links else {}),
|
||||
},
|
||||
)
|
||||
|
||||
payment_info = {
|
||||
"bill_id": bill_id,
|
||||
"order_id": order_id,
|
||||
"link_url": primary_link,
|
||||
"link_page_url": secondary_link,
|
||||
"local_payment_id": payment.id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"sbp_url": transfer_url or primary_link,
|
||||
"card_url": card_url,
|
||||
"transfer_url": transfer_url,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Создан Pal24 счет %s для пользователя %s на сумму %s",
|
||||
bill_id,
|
||||
user_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
|
||||
return payment_info
|
||||
|
||||
async def process_mulenpay_callback(self, db: AsyncSession, callback_data: dict) -> bool:
|
||||
try:
|
||||
uuid_value = callback_data.get("uuid")
|
||||
payment_status = (callback_data.get("payment_status") or "").lower()
|
||||
mulen_payment_id_raw = callback_data.get("id")
|
||||
mulen_payment_id_int: Optional[int] = None
|
||||
if mulen_payment_id_raw is not None:
|
||||
try:
|
||||
mulen_payment_id_int = int(mulen_payment_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
mulen_payment_id_int = None
|
||||
amount_value = callback_data.get("amount")
|
||||
|
||||
if not uuid_value and mulen_payment_id_raw is None:
|
||||
logger.error("MulenPay callback без uuid и id")
|
||||
return False
|
||||
|
||||
payment = None
|
||||
if uuid_value:
|
||||
payment = await get_mulenpay_payment_by_uuid(db, uuid_value)
|
||||
|
||||
if not payment and mulen_payment_id_int is not None:
|
||||
payment = await get_mulenpay_payment_by_mulen_id(db, mulen_payment_id_int)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"MulenPay платеж не найден (uuid=%s, id=%s)",
|
||||
uuid_value,
|
||||
mulen_payment_id_raw,
|
||||
)
|
||||
return False
|
||||
|
||||
if payment.transaction_id and payment.is_paid:
|
||||
logger.info("MulenPay платеж %s уже обработан", payment.uuid)
|
||||
return True
|
||||
|
||||
paid_at = datetime.utcnow()
|
||||
|
||||
if payment_status == "success":
|
||||
try:
|
||||
amount_kopeks = int(Decimal(str(amount_value)) * 100)
|
||||
except (InvalidOperation, TypeError):
|
||||
amount_kopeks = payment.amount_kopeks
|
||||
logger.warning(
|
||||
"Не удалось распарсить сумму MulenPay, используем значение из БД: %s",
|
||||
amount_value,
|
||||
)
|
||||
|
||||
if amount_kopeks != payment.amount_kopeks:
|
||||
logger.warning(
|
||||
"Несовпадение суммы MulenPay: callback=%s, ожидаемо=%s",
|
||||
amount_kopeks,
|
||||
payment.amount_kopeks,
|
||||
)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Mulen Pay ({mulen_payment_id_raw})",
|
||||
payment_method=PaymentMethod.MULENPAY,
|
||||
external_id=(
|
||||
str(mulen_payment_id_int)
|
||||
if mulen_payment_id_int is not None
|
||||
else payment.uuid
|
||||
),
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await link_mulenpay_payment_to_transaction(
|
||||
db,
|
||||
payment=payment,
|
||||
transaction_id=transaction.id,
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для MulenPay платежа", payment.user_id)
|
||||
return False
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot)
|
||||
except Exception as referral_error:
|
||||
logger.error(
|
||||
"Ошибка обработки реферального пополнения MulenPay: %s",
|
||||
referral_error,
|
||||
)
|
||||
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status="success",
|
||||
is_paid=True,
|
||||
paid_at=paid_at,
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления MulenPay: %s",
|
||||
notify_error,
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: Mulen Pay\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as user_notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления пользователю MulenPay: %s",
|
||||
user_notify_error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан MulenPay платеж %s для пользователя %s",
|
||||
payment.uuid,
|
||||
payment.user_id,
|
||||
)
|
||||
return True
|
||||
|
||||
if payment_status == "cancel":
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status="canceled",
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info("MulenPay платеж %s отменен", payment.uuid)
|
||||
return True
|
||||
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=payment_status or "unknown",
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
)
|
||||
logger.info(
|
||||
"Получен MulenPay callback со статусом %s для платежа %s",
|
||||
payment_status,
|
||||
payment.uuid,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обработки MulenPay callback: {error}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def process_pal24_postback(self, db: AsyncSession, payload: Dict[str, Any]) -> bool:
|
||||
|
||||
if not self.pal24_service or not self.pal24_service.is_configured:
|
||||
logger.error("Pal24 сервис не инициализирован")
|
||||
return False
|
||||
|
||||
try:
|
||||
order_id_raw = payload.get("InvId")
|
||||
order_id = str(order_id_raw) if order_id_raw is not None else None
|
||||
if not order_id:
|
||||
logger.error("Pal24 postback без InvId")
|
||||
return False
|
||||
|
||||
payment = await get_pal24_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
bill_id = payload.get("BillId")
|
||||
if bill_id:
|
||||
payment = await get_pal24_payment_by_bill_id(db, str(bill_id))
|
||||
|
||||
if not payment:
|
||||
logger.error("Pal24 платеж не найден для order_id=%s", order_id)
|
||||
return False
|
||||
|
||||
if payment.transaction_id and payment.is_paid:
|
||||
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
|
||||
return True
|
||||
|
||||
status = str(payload.get("Status", "UNKNOWN")).upper()
|
||||
payment_id = payload.get("TrsId")
|
||||
balance_amount = payload.get("BalanceAmount")
|
||||
balance_currency = payload.get("BalanceCurrency")
|
||||
payer_account = payload.get("AccountNumber")
|
||||
payment_method = payload.get("AccountType")
|
||||
|
||||
try:
|
||||
amount_kopeks = Pal24Service.convert_to_kopeks(str(payload.get("OutSum")))
|
||||
except Exception:
|
||||
logger.warning("Не удалось распарсить сумму Pal24, используем сохраненное значение")
|
||||
amount_kopeks = payment.amount_kopeks
|
||||
|
||||
if amount_kopeks != payment.amount_kopeks:
|
||||
logger.warning(
|
||||
"Несовпадение суммы Pal24: callback=%s, ожидаемо=%s",
|
||||
amount_kopeks,
|
||||
payment.amount_kopeks,
|
||||
)
|
||||
|
||||
is_success = status in Pal24Service.BILL_SUCCESS_STATES
|
||||
is_failed = status in Pal24Service.BILL_FAILED_STATES
|
||||
|
||||
await update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status,
|
||||
is_active=not is_failed,
|
||||
is_paid=is_success,
|
||||
payment_id=str(payment_id) if payment_id else None,
|
||||
payment_status=status,
|
||||
payment_method=str(payment_method) if payment_method else None,
|
||||
balance_amount=str(balance_amount) if balance_amount is not None else None,
|
||||
balance_currency=str(balance_currency) if balance_currency is not None else None,
|
||||
payer_account=str(payer_account) if payer_account is not None else None,
|
||||
callback_payload=payload,
|
||||
)
|
||||
|
||||
if not is_success:
|
||||
logger.info(
|
||||
"Получен Pal24 статус %s для платежа %s (успех=%s)",
|
||||
status,
|
||||
payment.bill_id,
|
||||
is_success,
|
||||
)
|
||||
return True
|
||||
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Pal24 платежа", payment.user_id)
|
||||
return False
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через Pal24 ({payment_id})",
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot)
|
||||
except Exception as referral_error:
|
||||
logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error("Ошибка отправки админ уведомления Pal24: %s", notify_error)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: PayPalych\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as user_notify_error:
|
||||
logger.error("Ошибка отправки уведомления пользователю Pal24: %s", user_notify_error)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Pal24 платеж %s для пользователя %s",
|
||||
payment.bill_id,
|
||||
payment.user_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _map_mulenpay_status(status_code: Optional[int]) -> str:
|
||||
mapping = {
|
||||
0: "created",
|
||||
1: "processing",
|
||||
2: "canceled",
|
||||
3: "success",
|
||||
4: "error",
|
||||
5: "hold",
|
||||
6: "hold",
|
||||
}
|
||||
return mapping.get(status_code, "unknown")
|
||||
|
||||
async def get_mulenpay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
payment = await get_mulenpay_payment_by_local_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_status_code = None
|
||||
remote_data = None
|
||||
|
||||
if (
|
||||
self.mulenpay_service
|
||||
and payment.mulen_payment_id is not None
|
||||
):
|
||||
response = await self.mulenpay_service.get_payment(payment.mulen_payment_id)
|
||||
if response and response.get("success"):
|
||||
remote_data = response.get("payment")
|
||||
if isinstance(remote_data, dict):
|
||||
remote_status_code = remote_data.get("status")
|
||||
mapped_status = self._map_mulenpay_status(remote_status_code)
|
||||
|
||||
if mapped_status == "success" and not payment.is_paid:
|
||||
await self.process_mulenpay_callback(
|
||||
db,
|
||||
{
|
||||
"uuid": payment.uuid,
|
||||
"payment_status": "success",
|
||||
"id": remote_data.get("id"),
|
||||
"amount": remote_data.get("amount"),
|
||||
},
|
||||
)
|
||||
payment = await get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
elif mapped_status and mapped_status != payment.status:
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=mapped_status,
|
||||
mulen_payment_id=remote_data.get("id"),
|
||||
)
|
||||
payment = await get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status_code": remote_status_code,
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения статуса MulenPay: {error}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_pal24_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
payment = await get_pal24_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
remote_status = None
|
||||
remote_data = None
|
||||
|
||||
if self.pal24_service and payment.bill_id:
|
||||
try:
|
||||
response = await self.pal24_service.get_bill_status(payment.bill_id)
|
||||
remote_data = response
|
||||
remote_status = (
|
||||
response.get("status")
|
||||
or response.get("bill", {}).get("status")
|
||||
)
|
||||
|
||||
if remote_status and remote_status != payment.status:
|
||||
await update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=str(remote_status).upper(),
|
||||
)
|
||||
payment = await get_pal24_payment_by_id(db, local_payment_id)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при получении статуса: %s", error)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status": remote_status,
|
||||
"remote_data": remote_data,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
|
||||
try:
|
||||
from app.database.crud.cryptobot import (
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _notify_admins_about_auto_assignment(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
old_group: Optional[PromoGroup],
|
||||
new_group: PromoGroup,
|
||||
total_spent_kopeks: int,
|
||||
):
|
||||
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
|
||||
return
|
||||
|
||||
bot_token = getattr(settings, "BOT_TOKEN", None)
|
||||
if not bot_token:
|
||||
logger.debug("BOT_TOKEN не настроен — пропускаем уведомление о промогруппе")
|
||||
return
|
||||
|
||||
bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode="HTML"))
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
reason = (
|
||||
f"Автоназначение за траты {settings.format_price(total_spent_kopeks)}"
|
||||
if hasattr(settings, "format_price")
|
||||
else f"Автоназначение за траты {total_spent_kopeks / 100:.2f}₽"
|
||||
)
|
||||
await notification_service.send_user_promo_group_change_notification(
|
||||
db,
|
||||
user,
|
||||
old_group,
|
||||
new_group,
|
||||
reason=reason,
|
||||
initiator=None,
|
||||
automatic=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о автоназначении промогруппы пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
await bot.session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _get_best_group_for_spending(
|
||||
db: AsyncSession,
|
||||
total_spent_kopeks: int,
|
||||
min_threshold_kopeks: int = 0,
|
||||
) -> Optional[PromoGroup]:
|
||||
if total_spent_kopeks <= 0:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
.order_by(PromoGroup.auto_assign_total_spent_kopeks.desc(), PromoGroup.id.desc())
|
||||
)
|
||||
groups = result.scalars().all()
|
||||
|
||||
for group in groups:
|
||||
threshold = group.auto_assign_total_spent_kopeks or 0
|
||||
if (
|
||||
threshold
|
||||
and total_spent_kopeks >= threshold
|
||||
and threshold > min_threshold_kopeks
|
||||
):
|
||||
return group
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def maybe_assign_promo_group_by_total_spent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[PromoGroup]:
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
|
||||
return None
|
||||
|
||||
old_group = None
|
||||
if user.promo_group_id:
|
||||
try:
|
||||
await db.refresh(user, attribute_names=["promo_group"])
|
||||
except Exception:
|
||||
pass
|
||||
old_group = getattr(user, "promo_group", None)
|
||||
|
||||
total_spent = await get_user_total_spent_kopeks(db, user_id)
|
||||
if total_spent <= 0:
|
||||
return None
|
||||
|
||||
previous_threshold = user.auto_promo_group_threshold_kopeks or 0
|
||||
|
||||
target_group = await _get_best_group_for_spending(
|
||||
db,
|
||||
total_spent,
|
||||
min_threshold_kopeks=previous_threshold,
|
||||
)
|
||||
if not target_group:
|
||||
return None
|
||||
|
||||
try:
|
||||
previous_group_id = user.promo_group_id
|
||||
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
|
||||
|
||||
if target_threshold <= previous_threshold:
|
||||
logger.debug(
|
||||
"Порог промогруппы '%s' (%s) не превышает ранее назначенный (%s) для пользователя %s",
|
||||
target_group.name,
|
||||
target_threshold,
|
||||
previous_threshold,
|
||||
user.telegram_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if user.auto_promo_group_assigned and target_group.id == previous_group_id:
|
||||
logger.debug(
|
||||
"Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
if target_threshold > previous_threshold:
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return target_group
|
||||
|
||||
user.auto_promo_group_assigned = True
|
||||
user.auto_promo_group_threshold_kopeks = target_threshold
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
user.promo_group_id = target_group.id
|
||||
user.promo_group = target_group
|
||||
logger.info(
|
||||
"🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s ₽",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
total_spent / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение",
|
||||
user.telegram_id,
|
||||
target_group.name,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
await _notify_admins_about_auto_assignment(
|
||||
db,
|
||||
user,
|
||||
old_group,
|
||||
target_group,
|
||||
total_spent,
|
||||
)
|
||||
|
||||
return target_group
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Ошибка при автоматическом назначении промогруппы пользователю %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
await db.rollback()
|
||||
return None
|
||||
@@ -49,23 +49,34 @@ class PromoCodeService:
|
||||
return {"success": False, "error": "already_used_by_user"}
|
||||
|
||||
result_description = await self._apply_promocode_effects(db, user, promocode)
|
||||
|
||||
|
||||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
await mark_user_as_had_paid_subscription(db, user)
|
||||
|
||||
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
|
||||
|
||||
|
||||
await create_promocode_use(db, promocode.id, user_id)
|
||||
|
||||
|
||||
promocode.current_uses += 1
|
||||
await db.commit()
|
||||
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
|
||||
|
||||
|
||||
promocode_data = {
|
||||
"code": promocode.code,
|
||||
"type": promocode.type,
|
||||
"balance_bonus_kopeks": promocode.balance_bonus_kopeks,
|
||||
"subscription_days": promocode.subscription_days,
|
||||
"max_uses": promocode.max_uses,
|
||||
"current_uses": promocode.current_uses,
|
||||
"valid_until": promocode.valid_until,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"description": result_description
|
||||
"description": result_description,
|
||||
"promocode": promocode_data,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict, List, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete
|
||||
@@ -20,17 +21,56 @@ from app.database.models import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemnaWaveConfigurationError(Exception):
|
||||
"""Raised when RemnaWave API configuration is missing."""
|
||||
|
||||
|
||||
class RemnaWaveService:
|
||||
|
||||
|
||||
def __init__(self):
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
base_url = (auth_params.get("base_url") or "").strip()
|
||||
api_key = (auth_params.get("api_key") or "").strip()
|
||||
|
||||
self._config_error: Optional[str] = None
|
||||
|
||||
if not base_url:
|
||||
self._config_error = "REMNAWAVE_API_URL не настроен"
|
||||
elif not api_key:
|
||||
self._config_error = "REMNAWAVE_API_KEY не настроен"
|
||||
|
||||
self.api: Optional[RemnaWaveAPI]
|
||||
if self._config_error:
|
||||
self.api = None
|
||||
else:
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=auth_params.get("secret_key"),
|
||||
username=auth_params.get("username"),
|
||||
password=auth_params.get("password")
|
||||
)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self._config_error is None
|
||||
|
||||
@property
|
||||
def configuration_error(self) -> Optional[str]:
|
||||
return self._config_error
|
||||
|
||||
def _ensure_configured(self) -> None:
|
||||
if not self.is_configured or self.api is None:
|
||||
raise RemnaWaveConfigurationError(
|
||||
self._config_error or "RemnaWave API не настроен"
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_api_client(self):
|
||||
self._ensure_configured()
|
||||
assert self.api is not None
|
||||
async with self.api as api:
|
||||
yield api
|
||||
|
||||
def _parse_remnawave_date(self, date_str: str) -> datetime:
|
||||
if not date_str:
|
||||
@@ -62,7 +102,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_system_statistics(self) -> Dict[str, Any]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
logger.info("Получение системной статистики RemnaWave...")
|
||||
|
||||
try:
|
||||
@@ -255,7 +295,7 @@ class RemnaWaveService:
|
||||
async def get_all_nodes(self) -> List[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
result = []
|
||||
@@ -284,7 +324,7 @@ class RemnaWaveService:
|
||||
async def test_connection(self) -> bool:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
stats = await api.get_system_stats()
|
||||
logger.info("✅ Соединение с Remnawave API работает")
|
||||
return True
|
||||
@@ -295,7 +335,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
node = await api.get_node_by_uuid(node_uuid)
|
||||
|
||||
if not node:
|
||||
@@ -321,7 +361,7 @@ class RemnaWaveService:
|
||||
|
||||
async def manage_node(self, node_uuid: str, action: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
if action == "enable":
|
||||
await api.enable_node(node_uuid)
|
||||
elif action == "disable":
|
||||
@@ -340,7 +380,7 @@ class RemnaWaveService:
|
||||
|
||||
async def restart_all_nodes(self) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
result = await api.restart_all_nodes()
|
||||
|
||||
if result:
|
||||
@@ -354,7 +394,7 @@ class RemnaWaveService:
|
||||
|
||||
async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'inbounds': inbound_uuids
|
||||
@@ -368,7 +408,7 @@ class RemnaWaveService:
|
||||
async def get_all_squads(self) -> List[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
squads = await api.get_internal_squads()
|
||||
|
||||
result = []
|
||||
@@ -390,7 +430,7 @@ class RemnaWaveService:
|
||||
|
||||
async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
squad = await api.create_internal_squad(name, inbounds)
|
||||
|
||||
logger.info(f"✅ Создан новый сквад: {name}")
|
||||
@@ -402,7 +442,7 @@ class RemnaWaveService:
|
||||
|
||||
async def update_squad(self, uuid: str, name: str = None, inbounds: List[str] = None) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
await api.update_internal_squad(uuid, name, inbounds)
|
||||
|
||||
logger.info(f"✅ Обновлен сквад {uuid}")
|
||||
@@ -414,7 +454,7 @@ class RemnaWaveService:
|
||||
|
||||
async def delete_squad(self, uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
result = await api.delete_internal_squad(uuid)
|
||||
|
||||
if result:
|
||||
@@ -432,7 +472,7 @@ class RemnaWaveService:
|
||||
|
||||
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
panel_users = []
|
||||
start = 0
|
||||
size = 100
|
||||
@@ -458,6 +498,7 @@ class RemnaWaveService:
|
||||
'usedTrafficBytes': user_obj.used_traffic_bytes,
|
||||
'hwidDeviceLimit': user_obj.hwid_device_limit,
|
||||
'subscriptionUrl': user_obj.subscription_url,
|
||||
'subscriptionCryptoLink': user_obj.happ_crypto_link,
|
||||
'activeInternalSquads': user_obj.active_internal_squads
|
||||
}
|
||||
panel_users.append(user_dict)
|
||||
@@ -549,7 +590,7 @@ class RemnaWaveService:
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
devices_reset = await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
if devices_reset:
|
||||
logger.info(f"🔧 Сброшены HWID устройства для пользователя {telegram_id}")
|
||||
@@ -581,6 +622,7 @@ class RemnaWaveService:
|
||||
subscription.autopay_enabled = False
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.subscription_url = ""
|
||||
subscription.subscription_crypto_link = ""
|
||||
|
||||
db_user.remnawave_uuid = None
|
||||
|
||||
@@ -637,14 +679,18 @@ class RemnaWaveService:
|
||||
subscription_data = {
|
||||
'user_id': user.id,
|
||||
'status': status.value,
|
||||
'is_trial': False,
|
||||
'is_trial': False,
|
||||
'end_date': expire_at,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'traffic_used_gb': traffic_used_gb,
|
||||
'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1,
|
||||
'connected_squads': squad_uuids,
|
||||
'remnawave_short_uuid': panel_user.get('shortUuid'),
|
||||
'subscription_url': panel_user.get('subscriptionUrl', '')
|
||||
'subscription_url': panel_user.get('subscriptionUrl', ''),
|
||||
'subscription_crypto_link': (
|
||||
panel_user.get('subscriptionCryptoLink')
|
||||
or (panel_user.get('happ') or {}).get('cryptoLink', '')
|
||||
)
|
||||
}
|
||||
|
||||
subscription = await create_subscription(db, **subscription_data)
|
||||
@@ -667,7 +713,11 @@ class RemnaWaveService:
|
||||
device_limit=1,
|
||||
connected_squads=[],
|
||||
remnawave_short_uuid=panel_user.get('shortUuid'),
|
||||
subscription_url=panel_user.get('subscriptionUrl', '')
|
||||
subscription_url=panel_user.get('subscriptionUrl', ''),
|
||||
subscription_crypto_link=(
|
||||
panel_user.get('subscriptionCryptoLink')
|
||||
or (panel_user.get('happ') or {}).get('cryptoLink', '')
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}")
|
||||
except Exception as basic_error:
|
||||
@@ -733,6 +783,13 @@ class RemnaWaveService:
|
||||
panel_url = panel_user.get('subscriptionUrl', '')
|
||||
if not subscription.subscription_url or subscription.subscription_url != panel_url:
|
||||
subscription.subscription_url = panel_url
|
||||
|
||||
panel_crypto_link = (
|
||||
panel_user.get('subscriptionCryptoLink')
|
||||
or (panel_user.get('happ') or {}).get('cryptoLink', '')
|
||||
)
|
||||
if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link:
|
||||
subscription.subscription_crypto_link = panel_crypto_link
|
||||
|
||||
active_squads = panel_user.get('activeInternalSquads', [])
|
||||
squad_uuids = []
|
||||
@@ -763,7 +820,7 @@ class RemnaWaveService:
|
||||
|
||||
users = await get_users_list(db, offset=0, limit=10000)
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
for user in users:
|
||||
if not user.subscription:
|
||||
continue
|
||||
@@ -825,7 +882,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_user_traffic_stats(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
users = await api.get_user_by_telegram_id(telegram_id)
|
||||
|
||||
if not users:
|
||||
@@ -848,17 +905,23 @@ class RemnaWaveService:
|
||||
return None
|
||||
|
||||
async def test_api_connection(self) -> Dict[str, Any]:
|
||||
if not self.is_configured:
|
||||
return {
|
||||
"status": "not_configured",
|
||||
"message": self.configuration_error or "RemnaWave API не настроен",
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
}
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
system_stats = await api.get_system_stats()
|
||||
|
||||
|
||||
return {
|
||||
"status": "connected",
|
||||
"message": "Подключение успешно",
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
"system_info": system_stats
|
||||
}
|
||||
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -866,6 +929,12 @@ class RemnaWaveService:
|
||||
"status_code": e.status_code,
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
except RemnaWaveConfigurationError as e:
|
||||
return {
|
||||
"status": "not_configured",
|
||||
"message": str(e),
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
@@ -875,7 +944,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
usage_data = await api.get_nodes_realtime_usage()
|
||||
return usage_data
|
||||
|
||||
@@ -885,7 +954,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
squad = await api.get_internal_squad_by_uuid(squad_uuid)
|
||||
if squad:
|
||||
return {
|
||||
@@ -902,7 +971,7 @@ class RemnaWaveService:
|
||||
|
||||
async def add_all_users_to_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
@@ -911,25 +980,16 @@ class RemnaWaveService:
|
||||
|
||||
async def remove_all_users_from_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing users from squad: {e}")
|
||||
return False
|
||||
|
||||
async def delete_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
response = await api.delete_internal_squad(squad_uuid)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting squad: {e}")
|
||||
return False
|
||||
|
||||
async def get_all_inbounds(self) -> List[Dict]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api._make_request('GET', '/api/config-profiles/inbounds')
|
||||
inbounds_data = response.get('response', {}).get('inbounds', [])
|
||||
|
||||
@@ -950,7 +1010,7 @@ class RemnaWaveService:
|
||||
|
||||
async def rename_squad(self, squad_uuid: str, new_name: str) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'name': new_name
|
||||
@@ -961,18 +1021,9 @@ class RemnaWaveService:
|
||||
logger.error(f"Error renaming squad: {e}")
|
||||
return False
|
||||
|
||||
async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with self.api as api:
|
||||
squad = await api.create_internal_squad(name, inbound_uuids)
|
||||
return squad is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating squad: {e}")
|
||||
return False
|
||||
|
||||
async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
start_str = start_date.isoformat() + "Z"
|
||||
end_str = end_date.isoformat() + "Z"
|
||||
|
||||
@@ -1050,7 +1101,7 @@ class RemnaWaveService:
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
devices_reset = await api.reset_user_devices(user.remnawave_uuid)
|
||||
if devices_reset:
|
||||
logger.info(f"🔧 Сброшены HWID устройства для {user.telegram_id}")
|
||||
@@ -1113,6 +1164,7 @@ class RemnaWaveService:
|
||||
user.subscription.autopay_days_before = 3
|
||||
user.subscription.remnawave_short_uuid = None
|
||||
user.subscription.subscription_url = ""
|
||||
user.subscription.subscription_crypto_link = ""
|
||||
user.subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
@@ -1136,7 +1188,7 @@ class RemnaWaveService:
|
||||
|
||||
logger.info("🧹 Начинаем усиленную очистку неактуальных подписок...")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
panel_users_data = await api._make_request('GET', '/api/users')
|
||||
panel_users = panel_users_data['response']['users']
|
||||
|
||||
@@ -1200,7 +1252,7 @@ class RemnaWaveService:
|
||||
|
||||
logger.info("🔄 Начинаем синхронизацию статусов подписок...")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
panel_users_data = await api._make_request('GET', '/api/users')
|
||||
panel_users = panel_users_data['response']['users']
|
||||
|
||||
@@ -1290,11 +1342,12 @@ class RemnaWaveService:
|
||||
|
||||
if not subscription.remnawave_short_uuid and user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
rw_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if rw_user:
|
||||
subscription.remnawave_short_uuid = rw_user.short_uuid
|
||||
subscription.subscription_url = rw_user.subscription_url
|
||||
subscription.subscription_crypto_link = rw_user.happ_crypto_link
|
||||
logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}")
|
||||
issues_fixed += 1
|
||||
except Exception as rw_error:
|
||||
@@ -1538,7 +1591,7 @@ class RemnaWaveService:
|
||||
try:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
api_available = True
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time as datetime_time, timedelta, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReportingServiceError(RuntimeError):
|
||||
"""Base error for the reporting service."""
|
||||
|
||||
|
||||
class ReportPeriod(Enum):
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY = "monthly"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReportPeriodRange:
|
||||
start_msk: datetime
|
||||
end_msk: datetime
|
||||
label: str
|
||||
|
||||
|
||||
class ReportingService:
|
||||
"""Generates admin summary reports and can schedule daily delivery."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.bot: Optional[Bot] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._moscow_tz = ZoneInfo("Europe/Moscow")
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
if not settings.ADMIN_REPORTS_ENABLED:
|
||||
logger.info("Сервис отчетов отключен настройками")
|
||||
return
|
||||
|
||||
if not self.bot:
|
||||
logger.warning("Невозможно запустить сервис отчетов без экземпляра бота")
|
||||
return
|
||||
|
||||
chat_id = settings.get_reports_chat_id()
|
||||
if not chat_id:
|
||||
logger.warning("Сервис отчетов не запущен: не указан чат для отправки отчетов")
|
||||
return
|
||||
|
||||
send_time = settings.get_reports_send_time()
|
||||
if not send_time:
|
||||
logger.warning("Сервис отчетов не запущен: не указано время ежедневной отправки")
|
||||
return
|
||||
|
||||
self._task = asyncio.create_task(self._auto_daily_loop(send_time))
|
||||
logger.info(
|
||||
"📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК",
|
||||
send_time.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def send_report(
|
||||
self,
|
||||
period: ReportPeriod,
|
||||
*,
|
||||
report_date: Optional[date] = None,
|
||||
send_to_topic: bool = False,
|
||||
) -> str:
|
||||
report_text = await self._build_report(period, report_date)
|
||||
|
||||
if send_to_topic:
|
||||
await self._deliver_report(report_text)
|
||||
|
||||
return report_text
|
||||
|
||||
async def _auto_daily_loop(self, send_time: datetime_time) -> None:
|
||||
try:
|
||||
next_run_utc, report_date = self._calculate_next_run(send_time)
|
||||
|
||||
while True:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
delay = (next_run_utc - now_utc).total_seconds()
|
||||
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
try:
|
||||
await self.send_report(
|
||||
ReportPeriod.DAILY,
|
||||
report_date=report_date,
|
||||
send_to_topic=True,
|
||||
)
|
||||
logger.info(
|
||||
"📊 Автоматический отчет за %s отправлен",
|
||||
report_date.strftime("%d.%m.%Y"),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка автоматической отправки отчета: %s", exc)
|
||||
|
||||
next_run_utc, report_date = self._calculate_next_run(send_time)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Сервис отчетов остановлен")
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Критическая ошибка в сервисе отчетов: %s", exc)
|
||||
|
||||
def _calculate_next_run(
|
||||
self,
|
||||
send_time: datetime_time,
|
||||
) -> Tuple[datetime, date]:
|
||||
now_msk = datetime.now(self._moscow_tz)
|
||||
candidate = datetime.combine(now_msk.date(), send_time, tzinfo=self._moscow_tz)
|
||||
|
||||
if now_msk >= candidate:
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
report_date = (candidate - timedelta(days=1)).date()
|
||||
return candidate.astimezone(timezone.utc), report_date
|
||||
|
||||
async def _deliver_report(self, report_text: str) -> None:
|
||||
if not self.bot:
|
||||
raise ReportingServiceError("Бот не инициализирован для отправки отчета")
|
||||
|
||||
chat_id = settings.get_reports_chat_id()
|
||||
if not chat_id:
|
||||
raise ReportingServiceError("Не задан чат для отправки отчета")
|
||||
|
||||
topic_id = settings.get_reports_topic_id()
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=report_text,
|
||||
message_thread_id=topic_id,
|
||||
)
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||
logger.error("Не удалось отправить отчет: %s", exc)
|
||||
raise ReportingServiceError("Не удалось отправить отчет в чат") from exc
|
||||
|
||||
async def _build_report(
|
||||
self,
|
||||
period: ReportPeriod,
|
||||
report_date: Optional[date],
|
||||
) -> str:
|
||||
period_range = self._get_period_range(period, report_date)
|
||||
start_utc = period_range.start_msk.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
end_utc = period_range.end_msk.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
totals = await self._collect_current_totals(session)
|
||||
period_stats = await self._collect_period_stats(session, start_utc, end_utc)
|
||||
|
||||
header = (
|
||||
f"📊 <b>Отчет за {period_range.label}</b>"
|
||||
if period == ReportPeriod.DAILY
|
||||
else f"📊 <b>Отчет за период {period_range.label}</b>"
|
||||
)
|
||||
|
||||
lines = [
|
||||
header,
|
||||
"",
|
||||
"🎯 <b>Триалы</b>",
|
||||
f"• Активных сейчас: {totals['active_trials']}",
|
||||
f"• Новых за период: {period_stats['new_trials']}",
|
||||
"",
|
||||
"💎 <b>Платные подписки</b>",
|
||||
f"• Активных сейчас: {totals['active_paid']}",
|
||||
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
|
||||
"",
|
||||
"🎟️ <b>Тикеты поддержки</b>",
|
||||
f"• Активных сейчас: {totals['open_tickets']}",
|
||||
f"• Новых за период: {period_stats['new_tickets']}",
|
||||
"",
|
||||
"💰 <b>Платежи</b>",
|
||||
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
|
||||
f"• Пополнений: {period_stats['deposits_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['deposits_amount'])}",
|
||||
f"• Всего поступлений: {period_stats['total_payments_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['total_payments_amount'])}",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_period_range(
|
||||
self,
|
||||
period: ReportPeriod,
|
||||
report_date: Optional[date],
|
||||
) -> ReportPeriodRange:
|
||||
now_msk = datetime.now(self._moscow_tz)
|
||||
|
||||
if period == ReportPeriod.DAILY:
|
||||
target_date = report_date or (now_msk.date() - timedelta(days=1))
|
||||
start = datetime.combine(target_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
end = start + timedelta(days=1)
|
||||
elif period == ReportPeriod.WEEKLY:
|
||||
end_date = report_date or now_msk.date()
|
||||
start_date = end_date - timedelta(days=7)
|
||||
start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
elif period == ReportPeriod.MONTHLY:
|
||||
end_date = report_date or now_msk.date()
|
||||
start_date = end_date - timedelta(days=30)
|
||||
start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz)
|
||||
else: # pragma: no cover - defensive branch
|
||||
raise ReportingServiceError(f"Неизвестный период отчета: {period}")
|
||||
|
||||
label = self._format_period_label(start, end)
|
||||
return ReportPeriodRange(start, end, label)
|
||||
|
||||
async def _collect_current_totals(self, session) -> dict:
|
||||
stats = await get_subscriptions_statistics(session)
|
||||
open_tickets_result = await session.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.status.in_(
|
||||
[
|
||||
TicketStatus.OPEN.value,
|
||||
TicketStatus.ANSWERED.value,
|
||||
TicketStatus.PENDING.value,
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
open_tickets = int(open_tickets_result.scalar() or 0)
|
||||
return {
|
||||
"active_trials": stats.get("trial_subscriptions", 0) or 0,
|
||||
"active_paid": stats.get("paid_subscriptions", 0) or 0,
|
||||
"open_tickets": open_tickets,
|
||||
}
|
||||
|
||||
async def _collect_period_stats(
|
||||
self,
|
||||
session,
|
||||
start_utc: datetime,
|
||||
end_utc: datetime,
|
||||
) -> dict:
|
||||
new_trials_result = await session.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.created_at >= start_utc,
|
||||
Subscription.created_at < end_utc,
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
new_trials = int(new_trials_result.scalar() or 0)
|
||||
|
||||
direct_paid_result = await session.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.created_at >= start_utc,
|
||||
Subscription.created_at < end_utc,
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
direct_paid = int(direct_paid_result.scalar() or 0)
|
||||
|
||||
conversions_result = await session.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
SubscriptionConversion.converted_at >= start_utc,
|
||||
SubscriptionConversion.converted_at < end_utc,
|
||||
)
|
||||
)
|
||||
conversions_count = int(conversions_result.scalar() or 0)
|
||||
|
||||
subscription_payments_row = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True, # noqa: E712
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
deposits_row = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(Transaction.id),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
).where(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True, # noqa: E712
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
subscription_payments_count = int(subscription_payments_row[0] or 0)
|
||||
subscription_payments_amount = int(subscription_payments_row[1] or 0)
|
||||
deposits_count = int(deposits_row[0] or 0)
|
||||
deposits_amount = int(deposits_row[1] or 0)
|
||||
|
||||
total_payments_count = subscription_payments_count + deposits_count
|
||||
total_payments_amount = subscription_payments_amount + deposits_amount
|
||||
new_tickets_result = await session.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.created_at >= start_utc,
|
||||
Ticket.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
new_tickets = int(new_tickets_result.scalar() or 0)
|
||||
|
||||
return {
|
||||
"new_trials": new_trials,
|
||||
"new_paid_subscriptions": direct_paid + conversions_count,
|
||||
"subscription_payments_count": subscription_payments_count,
|
||||
"subscription_payments_amount": subscription_payments_amount,
|
||||
"deposits_count": deposits_count,
|
||||
"deposits_amount": deposits_amount,
|
||||
"total_payments_count": total_payments_count,
|
||||
"total_payments_amount": total_payments_amount,
|
||||
"new_tickets": new_tickets,
|
||||
}
|
||||
|
||||
def _format_period_label(self, start: datetime, end: datetime) -> str:
|
||||
start_date = start.astimezone(self._moscow_tz).date()
|
||||
end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz)
|
||||
end_date = end_boundary.date()
|
||||
|
||||
if start_date == end_date:
|
||||
return start_date.strftime("%d.%m.%Y")
|
||||
|
||||
return (
|
||||
f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
|
||||
)
|
||||
|
||||
def _format_amount(self, amount_kopeks: int) -> str:
|
||||
if not amount_kopeks:
|
||||
return "0 ₽"
|
||||
|
||||
rubles = amount_kopeks / 100
|
||||
return f"{rubles:,.2f} ₽".replace(",", " ")
|
||||
|
||||
|
||||
reporting_service = ReportingService()
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerStatusEntry:
|
||||
address: str
|
||||
instance: Optional[str]
|
||||
protocol: Optional[str]
|
||||
name: str
|
||||
flag: str
|
||||
display_name: str
|
||||
latency_ms: Optional[int]
|
||||
is_online: bool
|
||||
|
||||
|
||||
class ServerStatusError(Exception):
|
||||
"""Raised when server status information cannot be fetched or parsed."""
|
||||
|
||||
|
||||
class ServerStatusService:
|
||||
_LATENCY_PATTERN = re.compile(
|
||||
r"xray_proxy_latency_ms\{(?P<labels>[^}]*)\}\s+(?P<value>[-+]?\d+(?:\.\d+)?)"
|
||||
)
|
||||
_STATUS_PATTERN = re.compile(
|
||||
r"xray_proxy_status\{(?P<labels>[^}]*)\}\s+(?P<value>[-+]?\d+(?:\.\d+)?)"
|
||||
)
|
||||
_LABEL_PATTERN = re.compile(r"(?P<key>[a-zA-Z_][a-zA-Z0-9_]*)=\"(?P<value>(?:\\.|[^\"])*)\"")
|
||||
_FLAG_PATTERN = re.compile(r"^([\U0001F1E6-\U0001F1FF]{2})\s*(.*)$")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
async def get_servers(self) -> List[ServerStatusEntry]:
|
||||
mode = settings.get_server_status_mode()
|
||||
if mode != "xray":
|
||||
raise ServerStatusError("Server status integration is not enabled")
|
||||
|
||||
url = settings.get_server_status_metrics_url()
|
||||
if not url:
|
||||
raise ServerStatusError("Metrics URL is not configured")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=settings.get_server_status_request_timeout())
|
||||
auth = None
|
||||
auth_credentials = settings.get_server_status_metrics_auth()
|
||||
if auth_credentials:
|
||||
username, password = auth_credentials
|
||||
auth = aiohttp.BasicAuth(username, password)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(
|
||||
url,
|
||||
auth=auth,
|
||||
ssl=settings.SERVER_STATUS_METRICS_VERIFY_SSL,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
text = await response.text()
|
||||
raise ServerStatusError(
|
||||
f"Unexpected response status: {response.status}"
|
||||
f" - {text[:200]}"
|
||||
)
|
||||
metrics_body = await response.text()
|
||||
except asyncio.TimeoutError as error:
|
||||
raise ServerStatusError("Request to metrics endpoint timed out") from error
|
||||
except aiohttp.ClientError as error:
|
||||
raise ServerStatusError("Failed to fetch metrics") from error
|
||||
|
||||
return self._parse_metrics(metrics_body)
|
||||
|
||||
def _parse_metrics(self, body: str) -> List[ServerStatusEntry]:
|
||||
servers: Dict[Tuple[str, str, str, str], ServerStatusEntry] = {}
|
||||
|
||||
for match in self._LATENCY_PATTERN.finditer(body):
|
||||
labels = self._parse_labels(match.group("labels"))
|
||||
key = self._build_key(labels)
|
||||
entry = servers.get(key)
|
||||
if not entry:
|
||||
entry = self._create_entry(labels)
|
||||
servers[key] = entry
|
||||
|
||||
try:
|
||||
value = float(match.group("value"))
|
||||
entry.latency_ms = int(round(value))
|
||||
except (TypeError, ValueError):
|
||||
entry.latency_ms = None
|
||||
|
||||
for match in self._STATUS_PATTERN.finditer(body):
|
||||
labels = self._parse_labels(match.group("labels"))
|
||||
key = self._build_key(labels)
|
||||
entry = servers.get(key)
|
||||
if not entry:
|
||||
entry = self._create_entry(labels)
|
||||
servers[key] = entry
|
||||
|
||||
try:
|
||||
value = float(match.group("value"))
|
||||
entry.is_online = value >= 1
|
||||
except (TypeError, ValueError):
|
||||
entry.is_online = False
|
||||
|
||||
return sorted(
|
||||
servers.values(),
|
||||
key=lambda item: (
|
||||
0 if item.is_online else 1,
|
||||
(item.display_name or item.name).lower(),
|
||||
),
|
||||
)
|
||||
|
||||
def _build_key(self, labels: Dict[str, str]) -> Tuple[str, str, str, str]:
|
||||
return (
|
||||
labels.get("address", ""),
|
||||
labels.get("instance", ""),
|
||||
labels.get("protocol", ""),
|
||||
labels.get("name", labels.get("address", "")),
|
||||
)
|
||||
|
||||
def _create_entry(self, labels: Dict[str, str]) -> ServerStatusEntry:
|
||||
name = labels.get("name") or labels.get("address") or "Unknown"
|
||||
flag, display_name = self._extract_flag(name)
|
||||
return ServerStatusEntry(
|
||||
address=labels.get("address", ""),
|
||||
instance=labels.get("instance"),
|
||||
protocol=labels.get("protocol"),
|
||||
name=name,
|
||||
flag=flag,
|
||||
display_name=display_name or name,
|
||||
latency_ms=None,
|
||||
is_online=False,
|
||||
)
|
||||
|
||||
def _extract_flag(self, name: str) -> Tuple[str, str]:
|
||||
match = self._FLAG_PATTERN.match(name)
|
||||
if not match:
|
||||
return "", name
|
||||
flag, remainder = match.groups()
|
||||
return flag, remainder.strip()
|
||||
|
||||
def _parse_labels(self, labels_str: str) -> Dict[str, str]:
|
||||
labels: Dict[str, str] = {}
|
||||
for match in self._LABEL_PATTERN.finditer(labels_str):
|
||||
key = match.group("key")
|
||||
value = match.group("value").replace('\\"', '"')
|
||||
labels[key] = value
|
||||
return labels
|
||||
|
||||
@@ -49,4 +49,10 @@ def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
|
||||
if subscription is None:
|
||||
return True
|
||||
|
||||
return bool(getattr(subscription, "is_trial", False))
|
||||
if getattr(subscription, "is_trial", False):
|
||||
return True
|
||||
|
||||
if getattr(subscription, "actual_status", None) == "expired":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, SubscriptionStatus
|
||||
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
@@ -19,6 +19,45 @@ from app.utils.pricing_utils import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_addon_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
group = promo_group or (getattr(user, "promo_group", None) if user else None)
|
||||
|
||||
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
|
||||
return 0
|
||||
|
||||
return _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
category,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
def get_traffic_reset_strategy():
|
||||
from app.config import settings
|
||||
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
|
||||
@@ -48,9 +87,12 @@ class SubscriptionService:
|
||||
)
|
||||
|
||||
async def create_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
*,
|
||||
reset_traffic: bool = False,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
@@ -91,6 +133,14 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
updated_user.uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
|
||||
username = f"user_{user.telegram_id}"
|
||||
@@ -109,9 +159,18 @@ class SubscriptionService:
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
updated_user.uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
user.remnawave_uuid = updated_user.uuid
|
||||
|
||||
await db.commit()
|
||||
@@ -132,7 +191,10 @@ class SubscriptionService:
|
||||
async def update_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
subscription: Subscription,
|
||||
*,
|
||||
reset_traffic: bool = False,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
@@ -170,7 +232,16 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
user.remnawave_uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
await db.commit()
|
||||
|
||||
status_text = "активным" if is_actually_active else "истёкшим"
|
||||
@@ -178,16 +249,37 @@ class SubscriptionService:
|
||||
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
|
||||
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
|
||||
return updated_user
|
||||
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _reset_user_traffic(
|
||||
self,
|
||||
api: RemnaWaveAPI,
|
||||
user_uuid: str,
|
||||
telegram_id: int,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
if not user_uuid:
|
||||
return
|
||||
|
||||
try:
|
||||
await api.reset_user_traffic(user_uuid)
|
||||
reason_text = f" ({reset_reason})" if reset_reason else ""
|
||||
logger.info(
|
||||
f"🔄 Сброшен трафик RemnaWave для пользователя {telegram_id}{reason_text}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"⚠️ Не удалось сбросить трафик RemnaWave для пользователя {telegram_id}: {exc}"
|
||||
)
|
||||
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
await api.disable_user(user_uuid)
|
||||
@@ -214,6 +306,7 @@ class SubscriptionService:
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}")
|
||||
@@ -266,9 +359,12 @@ class SubscriptionService:
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
@@ -277,70 +373,200 @@ class SubscriptionService:
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
|
||||
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_prices.append(server.price_kopeks)
|
||||
total_servers_price += server.price_kopeks
|
||||
logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}₽")
|
||||
server_price = server.price_kopeks
|
||||
server_discount = server_price * servers_discount_percent // 100
|
||||
discounted_server_price = server_price - server_discount
|
||||
server_prices.append(discounted_server_price)
|
||||
total_servers_price += discounted_server_price
|
||||
log_message = f"Сервер {server.display_name}: {server_price/100}₽"
|
||||
if server_discount > 0:
|
||||
log_message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount/100}₽ → {discounted_server_price/100}₽)"
|
||||
)
|
||||
logger.debug(log_message)
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_price = base_price + traffic_price + total_servers_price + devices_price
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price * devices_discount_percent // 100
|
||||
discounted_devices_price = devices_price - devices_discount
|
||||
|
||||
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки:")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
if traffic_price > 0:
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽")
|
||||
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if discounted_traffic_price > 0:
|
||||
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
if devices_price > 0:
|
||||
logger.info(f" Устройства ({devices}): {devices_price/100}₽")
|
||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||
if servers_discount_percent > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||
)
|
||||
logger.info(message)
|
||||
if discounted_devices_price > 0:
|
||||
message = f" Устройства ({devices}): {devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group.id if promo_group else None,
|
||||
)
|
||||
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount = servers_price * servers_discount_percent // 100
|
||||
discounted_servers_price = servers_price - servers_discount
|
||||
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price * devices_discount_percent // 100
|
||||
discounted_devices_price = devices_price - devices_discount
|
||||
|
||||
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
total_price = base_price + servers_price + devices_price + traffic_price
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
total_price = (
|
||||
base_price
|
||||
+ discounted_servers_price
|
||||
+ discounted_devices_price
|
||||
+ discounted_traffic_price
|
||||
)
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if servers_price > 0:
|
||||
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}₽")
|
||||
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽"
|
||||
if servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if devices_price > 0:
|
||||
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}₽")
|
||||
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if traffic_price > 0:
|
||||
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}₽")
|
||||
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
@@ -382,6 +608,7 @@ class SubscriptionService:
|
||||
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.subscription_url = ""
|
||||
subscription.subscription_crypto_link = ""
|
||||
subscription.connected_squads = []
|
||||
|
||||
user.remnawave_uuid = None
|
||||
@@ -397,9 +624,11 @@ class SubscriptionService:
|
||||
return False
|
||||
|
||||
async def get_countries_price_by_uuids(
|
||||
self,
|
||||
country_uuids: List[str],
|
||||
db: AsyncSession
|
||||
self,
|
||||
country_uuids: List[str],
|
||||
db: AsyncSession,
|
||||
*,
|
||||
promo_group_id: Optional[int] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
@@ -409,7 +638,12 @@ class SubscriptionService:
|
||||
|
||||
for country_uuid in country_uuids:
|
||||
server = await get_server_squad_by_uuid(db, country_uuid)
|
||||
if server and server.is_available and not server.is_full:
|
||||
is_allowed = True
|
||||
if promo_group_id is not None and server:
|
||||
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
is_allowed = promo_group_id in allowed_ids
|
||||
|
||||
if server and server.is_available and not server.is_full and is_allowed:
|
||||
price = server.price_kopeks
|
||||
total_price += price
|
||||
prices_list.append(price)
|
||||
@@ -440,9 +674,12 @@ class SubscriptionService:
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
@@ -453,91 +690,216 @@ class SubscriptionService:
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_price_total = server_price_per_month * months_in_period
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_price_total = discounted_server_per_month * months_in_period
|
||||
server_prices.append(server_price_total)
|
||||
total_servers_price += server_price_total
|
||||
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽")
|
||||
log_message = (
|
||||
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
log_message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.debug(log_message)
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||
if servers_discount_percent > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price_with_months(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group.id if promo_group else None,
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
period_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
||||
if base_discount_total > 0:
|
||||
base_log += (
|
||||
f" → {base_price/100}₽"
|
||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(base_log)
|
||||
if total_servers_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
|
||||
)
|
||||
if servers_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
@@ -556,32 +918,92 @@ class SubscriptionService:
|
||||
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = user.promo_group if user else None
|
||||
|
||||
total_price = 0
|
||||
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
total_price += traffic_price_per_month * months_to_pay
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay} = {traffic_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
traffic_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_price = discounted_traffic_per_month * months_to_pay
|
||||
total_price += traffic_total_price
|
||||
message = (
|
||||
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay}"
|
||||
f" = {traffic_total_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%:"
|
||||
f" -{traffic_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_price += devices_price_per_month * months_to_pay
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay} = {devices_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
devices_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_price = discounted_devices_per_month * months_to_pay
|
||||
total_price += devices_total_price
|
||||
message = (
|
||||
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay}"
|
||||
f" = {devices_total_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%:"
|
||||
f" -{devices_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids and db:
|
||||
for server_id in additional_server_ids:
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_total_price = server_price_per_month * months_to_pay
|
||||
servers_discount_percent = _resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = (
|
||||
server_price_per_month * servers_discount_percent // 100
|
||||
)
|
||||
discounted_server_per_month = (
|
||||
server_price_per_month - server_discount_per_month
|
||||
)
|
||||
server_total_price = discounted_server_per_month * months_to_pay
|
||||
total_price += server_total_price
|
||||
logger.info(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay} = {server_total_price/100}₽")
|
||||
|
||||
message = (
|
||||
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay}"
|
||||
f" = {server_total_price/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{server_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}₽")
|
||||
return total_price
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SupportSettingsService:
|
||||
"""Runtime editable support settings with JSON persistence."""
|
||||
|
||||
_storage_path: Path = Path("data/support_settings.json")
|
||||
_data: Dict = {}
|
||||
_loaded: bool = False
|
||||
|
||||
@classmethod
|
||||
def _ensure_dir(cls) -> None:
|
||||
try:
|
||||
cls._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure settings dir: {e}")
|
||||
|
||||
@classmethod
|
||||
def _load(cls) -> None:
|
||||
if cls._loaded:
|
||||
return
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
if cls._storage_path.exists():
|
||||
cls._data = json.loads(cls._storage_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
cls._data = {}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load support settings: {e}")
|
||||
cls._data = {}
|
||||
cls._loaded = True
|
||||
|
||||
@classmethod
|
||||
def _save(cls) -> bool:
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
cls._storage_path.write_text(json.dumps(cls._data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save support settings: {e}")
|
||||
return False
|
||||
|
||||
# Mode
|
||||
@classmethod
|
||||
def get_system_mode(cls) -> str:
|
||||
cls._load()
|
||||
mode = (cls._data.get("system_mode") or settings.get_support_system_mode()).strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
@classmethod
|
||||
def set_system_mode(cls, mode: str) -> bool:
|
||||
mode_clean = (mode or "").strip().lower()
|
||||
if mode_clean not in {"tickets", "contact", "both"}:
|
||||
return False
|
||||
cls._load()
|
||||
cls._data["system_mode"] = mode_clean
|
||||
return cls._save()
|
||||
|
||||
# Main menu visibility
|
||||
@classmethod
|
||||
def is_support_menu_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "menu_enabled" in cls._data:
|
||||
return bool(cls._data["menu_enabled"])
|
||||
return bool(settings.SUPPORT_MENU_ENABLED)
|
||||
|
||||
@classmethod
|
||||
def set_support_menu_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["menu_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
# Contact vs tickets helpers
|
||||
@classmethod
|
||||
def is_tickets_enabled(cls) -> bool:
|
||||
return cls.get_system_mode() in {"tickets", "both"}
|
||||
|
||||
@classmethod
|
||||
def is_contact_enabled(cls) -> bool:
|
||||
return cls.get_system_mode() in {"contact", "both"}
|
||||
|
||||
# Descriptions (per language)
|
||||
@classmethod
|
||||
def get_support_info_text(cls, language: str) -> str:
|
||||
cls._load()
|
||||
lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
overrides = cls._data.get("support_info_texts") or {}
|
||||
text = overrides.get(lang)
|
||||
if text and isinstance(text, str) and text.strip():
|
||||
return text
|
||||
# Fallback to dynamic localization default
|
||||
from app.localization.texts import get_texts
|
||||
return get_texts(lang).SUPPORT_INFO
|
||||
|
||||
@classmethod
|
||||
def set_support_info_text(cls, language: str, text: str) -> bool:
|
||||
cls._load()
|
||||
lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
texts_map = cls._data.get("support_info_texts") or {}
|
||||
texts_map[lang] = text or ""
|
||||
cls._data["support_info_texts"] = texts_map
|
||||
return cls._save()
|
||||
|
||||
|
||||
# Notifications & SLA
|
||||
@classmethod
|
||||
def get_admin_ticket_notifications_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "admin_ticket_notifications_enabled" in cls._data:
|
||||
return bool(cls._data["admin_ticket_notifications_enabled"])
|
||||
# fallback to global admin notifications setting
|
||||
return bool(settings.is_admin_notifications_enabled())
|
||||
|
||||
@classmethod
|
||||
def set_admin_ticket_notifications_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["admin_ticket_notifications_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def get_user_ticket_notifications_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "user_ticket_notifications_enabled" in cls._data:
|
||||
return bool(cls._data["user_ticket_notifications_enabled"])
|
||||
# fallback to global enable notifications
|
||||
return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True))
|
||||
|
||||
@classmethod
|
||||
def set_user_ticket_notifications_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["user_ticket_notifications_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def get_sla_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "ticket_sla_enabled" in cls._data:
|
||||
return bool(cls._data["ticket_sla_enabled"])
|
||||
return bool(getattr(settings, "SUPPORT_TICKET_SLA_ENABLED", True))
|
||||
|
||||
@classmethod
|
||||
def set_sla_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["ticket_sla_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def get_sla_minutes(cls) -> int:
|
||||
cls._load()
|
||||
minutes = cls._data.get("ticket_sla_minutes")
|
||||
if isinstance(minutes, int) and minutes > 0:
|
||||
return minutes
|
||||
return int(getattr(settings, "SUPPORT_TICKET_SLA_MINUTES", 5))
|
||||
|
||||
@classmethod
|
||||
def set_sla_minutes(cls, minutes: int) -> bool:
|
||||
try:
|
||||
minutes_int = int(minutes)
|
||||
except Exception:
|
||||
return False
|
||||
if minutes_int <= 0:
|
||||
return False
|
||||
cls._load()
|
||||
cls._data["ticket_sla_minutes"] = minutes_int
|
||||
return cls._save()
|
||||
|
||||
# Moderators management
|
||||
@classmethod
|
||||
def get_moderators(cls) -> list[int]:
|
||||
cls._load()
|
||||
raw = cls._data.get("moderators") or []
|
||||
moderators: list[int] = []
|
||||
for item in raw:
|
||||
try:
|
||||
moderators.append(int(item))
|
||||
except Exception:
|
||||
continue
|
||||
return moderators
|
||||
|
||||
@classmethod
|
||||
def is_moderator(cls, telegram_id: int) -> bool:
|
||||
try:
|
||||
tid = int(telegram_id)
|
||||
except Exception:
|
||||
return False
|
||||
return tid in cls.get_moderators()
|
||||
|
||||
@classmethod
|
||||
def add_moderator(cls, telegram_id: int) -> bool:
|
||||
try:
|
||||
tid = int(telegram_id)
|
||||
except Exception:
|
||||
return False
|
||||
cls._load()
|
||||
moderators = set(cls.get_moderators())
|
||||
moderators.add(tid)
|
||||
cls._data["moderators"] = sorted(moderators)
|
||||
return cls._save()
|
||||
|
||||
@classmethod
|
||||
def remove_moderator(cls, telegram_id: int) -> bool:
|
||||
try:
|
||||
tid = int(telegram_id)
|
||||
except Exception:
|
||||
return False
|
||||
cls._load()
|
||||
moderators = set(cls.get_moderators())
|
||||
if tid in moderators:
|
||||
moderators.remove(tid)
|
||||
cls._data["moderators"] = sorted(moderators)
|
||||
return cls._save()
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
|
||||
|
||||
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.database.crud.system_setting import (
|
||||
delete_system_setting,
|
||||
upsert_system_setting,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _title_from_key(key: str) -> str:
|
||||
parts = key.split("_")
|
||||
if not parts:
|
||||
return key
|
||||
return " ".join(part.capitalize() for part in parts)
|
||||
|
||||
|
||||
def _truncate(value: str, max_len: int = 60) -> str:
|
||||
value = value.strip()
|
||||
if len(value) <= max_len:
|
||||
return value
|
||||
return value[: max_len - 1] + "…"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SettingDefinition:
|
||||
key: str
|
||||
category_key: str
|
||||
category_label: str
|
||||
python_type: Type[Any]
|
||||
type_label: str
|
||||
is_optional: bool
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return _title_from_key(self.key)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChoiceOption:
|
||||
value: Any
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class BotConfigurationService:
|
||||
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
|
||||
|
||||
CATEGORY_TITLES: Dict[str, str] = {
|
||||
"SUPPORT": "💬 Ссылка на поддержку",
|
||||
"LOCALIZATION": "🌍 Языковые настройки",
|
||||
"MAINTENANCE": "🛠️ Режим обслуживания",
|
||||
"CHANNEL": "📣 Обязательная подписка",
|
||||
"ADMIN_NOTIFICATIONS": "🔔 Уведомления админам",
|
||||
"ADMIN_REPORTS": "📊 Автоматические отчеты",
|
||||
"TRIAL": "🎁 Триальная подписка",
|
||||
"PAID_SUBSCRIPTION": "💰 Платные подписки",
|
||||
"PERIODS": "📅 Периоды подписки",
|
||||
"SUBSCRIPTION_PRICES": "💵 Цены за периоды",
|
||||
"TRAFFIC": "🚦 Настройки трафика",
|
||||
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
|
||||
"DISCOUNTS": "🎯 Промо и скидки",
|
||||
"PAYMENT": "⚙️ Общие настройки платежей",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "💎 CryptoBot",
|
||||
"YOOKASSA": "💸 YooKassa",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 MulenPay",
|
||||
"PAL24": "🏦 Pal24/PayPalych",
|
||||
"REMNAWAVE": "🔗 RemnaWave API",
|
||||
"REFERRAL": "🤝 Реферальная система",
|
||||
"AUTOPAY": "🔄 Автопродление",
|
||||
"INTERFACE_BRANDING": "🖼️ Визуальные настройки",
|
||||
"INTERFACE_SUBSCRIPTION": "🔗 Скрыть ссылку подписки",
|
||||
"CONNECT_BUTTON": "🚀 Кнопка «Подключиться»",
|
||||
"HAPP": "🅷 Happ настройки",
|
||||
"SKIP": "⚡ Быстрый старт",
|
||||
"ADDITIONAL": "📱 Приложения и DeepLinks",
|
||||
"MINIAPP": "📱 Mini App",
|
||||
"DATABASE": "🗄️ Режим БД",
|
||||
"POSTGRES": "🐘 PostgreSQL",
|
||||
"SQLITE": "💾 SQLite",
|
||||
"REDIS": "🧠 Redis",
|
||||
"MONITORING": "📈 Общий мониторинг",
|
||||
"NOTIFICATIONS": "🔔 Уведомления пользователям",
|
||||
"SERVER": "🖥️ Статус серверов",
|
||||
"BACKUP": "💾 Система бэкапов",
|
||||
"VERSION": "🔄 Обновления",
|
||||
"LOG": "📝 Логирование",
|
||||
"WEBHOOK": "🌐 Webhook",
|
||||
"WEB_API": "🌐 Web API",
|
||||
"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",
|
||||
"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",
|
||||
"REFERRED_USER_REWARD": "REFERRAL",
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
"TRIAL_WARNING_HOURS": "NOTIFICATIONS",
|
||||
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
|
||||
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
|
||||
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
|
||||
"LOGO_FILE": "INTERFACE_BRANDING",
|
||||
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
|
||||
"CONNECT_BUTTON_MODE": "CONNECT_BUTTON",
|
||||
"MINIAPP_CUSTOM_URL": "CONNECT_BUTTON",
|
||||
"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",
|
||||
}
|
||||
|
||||
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
|
||||
"SUPPORT_": "SUPPORT",
|
||||
"ADMIN_NOTIFICATIONS": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_REPORTS": "ADMIN_REPORTS",
|
||||
"CHANNEL_": "CHANNEL",
|
||||
"POSTGRES_": "POSTGRES",
|
||||
"SQLITE_": "SQLITE",
|
||||
"REDIS_": "REDIS",
|
||||
"REMNAWAVE": "REMNAWAVE",
|
||||
"TRIAL_": "TRIAL",
|
||||
"TRAFFIC_PACKAGES": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC": "TRAFFIC_PACKAGES",
|
||||
"TRAFFIC_": "TRAFFIC",
|
||||
"REFERRAL_": "REFERRAL",
|
||||
"AUTOPAY_": "AUTOPAY",
|
||||
"TELEGRAM_STARS": "TELEGRAM",
|
||||
"TRIBUTE_": "TRIBUTE",
|
||||
"YOOKASSA_": "YOOKASSA",
|
||||
"CRYPTOBOT_": "CRYPTOBOT",
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
"CONNECT_BUTTON_HAPP": "HAPP",
|
||||
"HAPP_": "HAPP",
|
||||
"SKIP_": "SKIP",
|
||||
"MINIAPP_": "MINIAPP",
|
||||
"MONITORING_": "MONITORING",
|
||||
"NOTIFICATION_": "NOTIFICATIONS",
|
||||
"SERVER_STATUS": "SERVER",
|
||||
"MAINTENANCE_": "MAINTENANCE",
|
||||
"VERSION_CHECK": "VERSION",
|
||||
"BACKUP_": "BACKUP",
|
||||
"WEBHOOK_": "WEBHOOK",
|
||||
"LOG_": "LOG",
|
||||
"WEB_API_": "WEB_API",
|
||||
"DEBUG": "DEBUG",
|
||||
}
|
||||
|
||||
CHOICES: Dict[str, List[ChoiceOption]] = {
|
||||
"DATABASE_MODE": [
|
||||
ChoiceOption("auto", "🤖 Авто"),
|
||||
ChoiceOption("postgresql", "🐘 PostgreSQL"),
|
||||
ChoiceOption("sqlite", "💾 SQLite"),
|
||||
],
|
||||
"REMNAWAVE_AUTH_TYPE": [
|
||||
ChoiceOption("api_key", "🔑 API Key"),
|
||||
ChoiceOption("basic_auth", "🧾 Basic Auth"),
|
||||
],
|
||||
"REMNAWAVE_USER_DELETE_MODE": [
|
||||
ChoiceOption("delete", "🗑 Удалять"),
|
||||
ChoiceOption("disable", "🚫 Деактивировать"),
|
||||
],
|
||||
"TRAFFIC_SELECTION_MODE": [
|
||||
ChoiceOption("selectable", "📦 Выбор пакетов"),
|
||||
ChoiceOption("fixed", "📏 Фиксированный лимит"),
|
||||
],
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": [
|
||||
ChoiceOption("NO_RESET", "♾️ Без сброса"),
|
||||
ChoiceOption("DAY", "📅 Ежедневно"),
|
||||
ChoiceOption("WEEK", "🗓 Еженедельно"),
|
||||
ChoiceOption("MONTH", "📆 Ежемесячно"),
|
||||
],
|
||||
"SUPPORT_SYSTEM_MODE": [
|
||||
ChoiceOption("tickets", "🎫 Только тикеты"),
|
||||
ChoiceOption("contact", "💬 Только контакт"),
|
||||
ChoiceOption("both", "🔁 Оба варианта"),
|
||||
],
|
||||
"CONNECT_BUTTON_MODE": [
|
||||
ChoiceOption("guide", "📘 Гайд"),
|
||||
ChoiceOption("miniapp_subscription", "🧾 Mini App подписка"),
|
||||
ChoiceOption("miniapp_custom", "🧩 Mini App (ссылка)"),
|
||||
ChoiceOption("link", "🔗 Прямая ссылка"),
|
||||
ChoiceOption("happ_cryptolink", "🪙 Happ CryptoLink"),
|
||||
],
|
||||
"SERVER_STATUS_MODE": [
|
||||
ChoiceOption("disabled", "🚫 Отключено"),
|
||||
ChoiceOption("external_link", "🌐 Внешняя ссылка"),
|
||||
ChoiceOption("external_link_miniapp", "🧭 Mini App ссылка"),
|
||||
ChoiceOption("xray", "📊 XRay Checker"),
|
||||
],
|
||||
"YOOKASSA_PAYMENT_MODE": [
|
||||
ChoiceOption("full_payment", "💳 Полная оплата"),
|
||||
ChoiceOption("partial_payment", "🪙 Частичная оплата"),
|
||||
ChoiceOption("advance", "💼 Аванс"),
|
||||
ChoiceOption("full_prepayment", "📦 Полная предоплата"),
|
||||
ChoiceOption("partial_prepayment", "📦 Частичная предоплата"),
|
||||
ChoiceOption("credit", "💰 Кредит"),
|
||||
ChoiceOption("credit_payment", "💸 Погашение кредита"),
|
||||
],
|
||||
"YOOKASSA_PAYMENT_SUBJECT": [
|
||||
ChoiceOption("commodity", "📦 Товар"),
|
||||
ChoiceOption("excise", "🥃 Подакцизный товар"),
|
||||
ChoiceOption("job", "🛠 Работа"),
|
||||
ChoiceOption("service", "🧾 Услуга"),
|
||||
ChoiceOption("gambling_bet", "🎲 Ставка"),
|
||||
ChoiceOption("gambling_prize", "🏆 Выигрыш"),
|
||||
ChoiceOption("lottery", "🎫 Лотерея"),
|
||||
ChoiceOption("lottery_prize", "🎁 Приз лотереи"),
|
||||
ChoiceOption("intellectual_activity", "🧠 Интеллектуальная деятельность"),
|
||||
ChoiceOption("payment", "💱 Платеж"),
|
||||
ChoiceOption("agent_commission", "🤝 Комиссия агента"),
|
||||
ChoiceOption("composite", "🧩 Композитный"),
|
||||
ChoiceOption("another", "📄 Другое"),
|
||||
],
|
||||
"YOOKASSA_VAT_CODE": [
|
||||
ChoiceOption(1, "1 — НДС не облагается"),
|
||||
ChoiceOption(2, "2 — НДС 0%"),
|
||||
ChoiceOption(3, "3 — НДС 10%"),
|
||||
ChoiceOption(4, "4 — НДС 20%"),
|
||||
ChoiceOption(5, "5 — НДС 10/110"),
|
||||
ChoiceOption(6, "6 — НДС 20/120"),
|
||||
],
|
||||
"MULENPAY_LANGUAGE": [
|
||||
ChoiceOption("ru", "🇷🇺 Русский"),
|
||||
ChoiceOption("en", "🇬🇧 Английский"),
|
||||
],
|
||||
"LOG_LEVEL": [
|
||||
ChoiceOption("DEBUG", "🐞 Debug"),
|
||||
ChoiceOption("INFO", "ℹ️ Info"),
|
||||
ChoiceOption("WARNING", "⚠️ Warning"),
|
||||
ChoiceOption("ERROR", "❌ Error"),
|
||||
ChoiceOption("CRITICAL", "🔥 Critical"),
|
||||
],
|
||||
}
|
||||
|
||||
_definitions: Dict[str, SettingDefinition] = {}
|
||||
_original_values: Dict[str, Any] = settings.model_dump()
|
||||
_overrides_raw: Dict[str, Optional[str]] = {}
|
||||
_callback_tokens: Dict[str, str] = {}
|
||||
_token_to_key: Dict[str, str] = {}
|
||||
_choice_tokens: Dict[str, Dict[Any, str]] = {}
|
||||
_choice_token_lookup: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@classmethod
|
||||
def initialize_definitions(cls) -> None:
|
||||
if cls._definitions:
|
||||
return
|
||||
|
||||
for key, field in Settings.model_fields.items():
|
||||
if key in cls.EXCLUDED_KEYS:
|
||||
continue
|
||||
|
||||
annotation = field.annotation
|
||||
python_type, is_optional = cls._normalize_type(annotation)
|
||||
type_label = cls._type_to_label(python_type, is_optional)
|
||||
|
||||
category_key = cls._resolve_category_key(key)
|
||||
category_label = cls.CATEGORY_TITLES.get(
|
||||
category_key,
|
||||
category_key.capitalize() if category_key else "Прочее",
|
||||
)
|
||||
|
||||
cls._definitions[key] = SettingDefinition(
|
||||
key=key,
|
||||
category_key=category_key or "other",
|
||||
category_label=category_label,
|
||||
python_type=python_type,
|
||||
type_label=type_label,
|
||||
is_optional=is_optional,
|
||||
)
|
||||
|
||||
cls._register_callback_token(key)
|
||||
if key in cls.CHOICES:
|
||||
cls._ensure_choice_tokens(key)
|
||||
|
||||
|
||||
@classmethod
|
||||
def _resolve_category_key(cls, key: str) -> str:
|
||||
override = cls.CATEGORY_KEY_OVERRIDES.get(key)
|
||||
if override:
|
||||
return override
|
||||
|
||||
for prefix, category in sorted(
|
||||
cls.CATEGORY_PREFIX_OVERRIDES.items(), key=lambda item: len(item[0]), reverse=True
|
||||
):
|
||||
if key.startswith(prefix):
|
||||
return category
|
||||
|
||||
if "_" not in key:
|
||||
return key.upper()
|
||||
prefix = key.split("_", 1)[0]
|
||||
return prefix.upper()
|
||||
|
||||
@classmethod
|
||||
def _normalize_type(cls, annotation: Any) -> Tuple[Type[Any], bool]:
|
||||
if annotation is None:
|
||||
return str, True
|
||||
|
||||
origin = get_origin(annotation)
|
||||
if origin is Union:
|
||||
args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
||||
if len(args) == 1:
|
||||
nested_type, nested_optional = cls._normalize_type(args[0])
|
||||
return nested_type, True
|
||||
return str, True
|
||||
|
||||
if annotation in {int, float, bool, str}:
|
||||
return annotation, False
|
||||
|
||||
if annotation in {Optional[int], Optional[float], Optional[bool], Optional[str]}:
|
||||
nested = get_args(annotation)[0]
|
||||
return nested, True
|
||||
|
||||
# Paths, lists, dicts и прочее будем хранить как строки
|
||||
return str, False
|
||||
|
||||
@classmethod
|
||||
def _type_to_label(cls, python_type: Type[Any], is_optional: bool) -> str:
|
||||
base = {
|
||||
bool: "bool",
|
||||
int: "int",
|
||||
float: "float",
|
||||
str: "str",
|
||||
}.get(python_type, "str")
|
||||
return f"optional[{base}]" if is_optional else base
|
||||
|
||||
@classmethod
|
||||
def get_categories(cls) -> List[Tuple[str, str, int]]:
|
||||
cls.initialize_definitions()
|
||||
categories: Dict[str, List[SettingDefinition]] = {}
|
||||
|
||||
for definition in cls._definitions.values():
|
||||
categories.setdefault(definition.category_key, []).append(definition)
|
||||
|
||||
result: List[Tuple[str, str, int]] = []
|
||||
for category_key, items in categories.items():
|
||||
label = items[0].category_label
|
||||
result.append((category_key, label, len(items)))
|
||||
|
||||
result.sort(key=lambda item: item[1])
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_settings_for_category(cls, category_key: str) -> List[SettingDefinition]:
|
||||
cls.initialize_definitions()
|
||||
filtered = [
|
||||
definition
|
||||
for definition in cls._definitions.values()
|
||||
if definition.category_key == category_key
|
||||
]
|
||||
filtered.sort(key=lambda definition: definition.key)
|
||||
return filtered
|
||||
|
||||
@classmethod
|
||||
def get_definition(cls, key: str) -> SettingDefinition:
|
||||
cls.initialize_definitions()
|
||||
return cls._definitions[key]
|
||||
|
||||
@classmethod
|
||||
def has_override(cls, key: str) -> bool:
|
||||
return key in cls._overrides_raw
|
||||
|
||||
@classmethod
|
||||
def get_current_value(cls, key: str) -> Any:
|
||||
return getattr(settings, key)
|
||||
|
||||
@classmethod
|
||||
def get_original_value(cls, key: str) -> Any:
|
||||
return cls._original_values.get(key)
|
||||
|
||||
@classmethod
|
||||
def format_value(cls, value: Any) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
if isinstance(value, bool):
|
||||
return "✅ Да" if value else "❌ Нет"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, (list, dict, tuple, set)):
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def format_value_for_list(cls, key: str) -> str:
|
||||
value = cls.get_current_value(key)
|
||||
formatted = cls.format_value(value)
|
||||
if formatted == "—":
|
||||
return formatted
|
||||
return _truncate(formatted)
|
||||
|
||||
@classmethod
|
||||
def get_choice_options(cls, key: str) -> List[ChoiceOption]:
|
||||
cls.initialize_definitions()
|
||||
return cls.CHOICES.get(key, [])
|
||||
|
||||
@classmethod
|
||||
def has_choices(cls, key: str) -> bool:
|
||||
return bool(cls.get_choice_options(key))
|
||||
|
||||
@classmethod
|
||||
def get_callback_token(cls, key: str) -> str:
|
||||
cls.initialize_definitions()
|
||||
return cls._callback_tokens[key]
|
||||
|
||||
@classmethod
|
||||
def resolve_callback_token(cls, token: str) -> str:
|
||||
cls.initialize_definitions()
|
||||
return cls._token_to_key[token]
|
||||
|
||||
@classmethod
|
||||
def get_choice_token(cls, key: str, value: Any) -> Optional[str]:
|
||||
cls.initialize_definitions()
|
||||
cls._ensure_choice_tokens(key)
|
||||
return cls._choice_tokens.get(key, {}).get(value)
|
||||
|
||||
@classmethod
|
||||
def resolve_choice_token(cls, key: str, token: str) -> Any:
|
||||
cls.initialize_definitions()
|
||||
cls._ensure_choice_tokens(key)
|
||||
return cls._choice_token_lookup.get(key, {})[token]
|
||||
|
||||
@classmethod
|
||||
def _register_callback_token(cls, key: str) -> None:
|
||||
if key in cls._callback_tokens:
|
||||
return
|
||||
|
||||
base = hashlib.blake2s(key.encode("utf-8"), digest_size=6).hexdigest()
|
||||
candidate = base
|
||||
counter = 1
|
||||
while candidate in cls._token_to_key and cls._token_to_key[candidate] != key:
|
||||
suffix = cls._encode_base36(counter)
|
||||
candidate = f"{base}{suffix}"[:16]
|
||||
counter += 1
|
||||
|
||||
cls._callback_tokens[key] = candidate
|
||||
cls._token_to_key[candidate] = key
|
||||
|
||||
@classmethod
|
||||
def _ensure_choice_tokens(cls, key: str) -> None:
|
||||
if key in cls._choice_tokens:
|
||||
return
|
||||
|
||||
options = cls.CHOICES.get(key, [])
|
||||
value_to_token: Dict[Any, str] = {}
|
||||
token_to_value: Dict[str, Any] = {}
|
||||
|
||||
for index, option in enumerate(options):
|
||||
token = cls._encode_base36(index)
|
||||
value_to_token[option.value] = token
|
||||
token_to_value[token] = option.value
|
||||
|
||||
cls._choice_tokens[key] = value_to_token
|
||||
cls._choice_token_lookup[key] = token_to_value
|
||||
|
||||
@staticmethod
|
||||
def _encode_base36(number: int) -> str:
|
||||
if number < 0:
|
||||
raise ValueError("number must be non-negative")
|
||||
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
if number == 0:
|
||||
return "0"
|
||||
result = []
|
||||
while number:
|
||||
number, rem = divmod(number, 36)
|
||||
result.append(alphabet[rem])
|
||||
return "".join(reversed(result))
|
||||
|
||||
@classmethod
|
||||
async def initialize(cls) -> None:
|
||||
cls.initialize_definitions()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(SystemSetting))
|
||||
rows = result.scalars().all()
|
||||
|
||||
overrides: Dict[str, Optional[str]] = {}
|
||||
for row in rows:
|
||||
if row.key in cls._definitions:
|
||||
overrides[row.key] = row.value
|
||||
|
||||
for key, raw_value in overrides.items():
|
||||
try:
|
||||
parsed_value = cls.deserialize_value(key, raw_value)
|
||||
except Exception as error:
|
||||
logger.error("Не удалось применить настройку %s: %s", key, error)
|
||||
continue
|
||||
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, parsed_value)
|
||||
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
async def reload(cls) -> None:
|
||||
cls._overrides_raw.clear()
|
||||
await cls.initialize()
|
||||
|
||||
@classmethod
|
||||
def deserialize_value(cls, key: str, raw_value: Optional[str]) -> Any:
|
||||
if raw_value is None:
|
||||
return None
|
||||
|
||||
definition = cls.get_definition(key)
|
||||
python_type = definition.python_type
|
||||
|
||||
if python_type is bool:
|
||||
value_lower = raw_value.strip().lower()
|
||||
if value_lower in {"1", "true", "on", "yes", "да"}:
|
||||
return True
|
||||
if value_lower in {"0", "false", "off", "no", "нет"}:
|
||||
return False
|
||||
raise ValueError(f"Неверное булево значение: {raw_value}")
|
||||
|
||||
if python_type is int:
|
||||
return int(raw_value)
|
||||
|
||||
if python_type is float:
|
||||
return float(raw_value)
|
||||
|
||||
return raw_value
|
||||
|
||||
@classmethod
|
||||
def serialize_value(cls, key: str, value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
definition = cls.get_definition(key)
|
||||
python_type = definition.python_type
|
||||
|
||||
if python_type is bool:
|
||||
return "true" if value else "false"
|
||||
if python_type in {int, float}:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def parse_user_value(cls, key: str, user_input: str) -> Any:
|
||||
definition = cls.get_definition(key)
|
||||
text = (user_input or "").strip()
|
||||
|
||||
if text.lower() in {"отмена", "cancel"}:
|
||||
raise ValueError("Ввод отменен пользователем")
|
||||
|
||||
if definition.is_optional and text.lower() in {"none", "null", "пусто", ""}:
|
||||
return None
|
||||
|
||||
python_type = definition.python_type
|
||||
|
||||
if python_type is bool:
|
||||
lowered = text.lower()
|
||||
if lowered in {"1", "true", "on", "yes", "да", "вкл", "enable", "enabled"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "off", "no", "нет", "выкл", "disable", "disabled"}:
|
||||
return False
|
||||
raise ValueError("Введите 'true' или 'false' (или 'да'/'нет')")
|
||||
|
||||
if python_type is int:
|
||||
parsed_value: Any = int(text)
|
||||
elif python_type is float:
|
||||
parsed_value = float(text.replace(",", "."))
|
||||
else:
|
||||
parsed_value = text
|
||||
|
||||
choices = cls.get_choice_options(key)
|
||||
if choices:
|
||||
allowed_values = {option.value for option in choices}
|
||||
if python_type is str:
|
||||
lowered_map = {
|
||||
str(option.value).lower(): option.value for option in choices
|
||||
}
|
||||
normalized = lowered_map.get(str(parsed_value).lower())
|
||||
if normalized is not None:
|
||||
parsed_value = normalized
|
||||
elif parsed_value not in allowed_values:
|
||||
readable = ", ".join(
|
||||
f"{option.label} ({cls.format_value(option.value)})" for option in choices
|
||||
)
|
||||
raise ValueError(f"Доступные значения: {readable}")
|
||||
elif parsed_value not in allowed_values:
|
||||
readable = ", ".join(
|
||||
f"{option.label} ({cls.format_value(option.value)})" for option in choices
|
||||
)
|
||||
raise ValueError(f"Доступные значения: {readable}")
|
||||
|
||||
return parsed_value
|
||||
|
||||
@classmethod
|
||||
async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None:
|
||||
raw_value = cls.serialize_value(key, value)
|
||||
await upsert_system_setting(db, key, raw_value)
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, value)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
async def reset_value(cls, db: AsyncSession, key: str) -> None:
|
||||
await delete_system_setting(db, key)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
original = cls.get_original_value(key)
|
||||
cls._apply_to_settings(key, original)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
def _apply_to_settings(cls, key: str, value: Any) -> None:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except Exception as error:
|
||||
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
|
||||
|
||||
@staticmethod
|
||||
async def _sync_default_web_api_token() -> None:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if not default_token:
|
||||
return
|
||||
|
||||
success = await ensure_default_web_api_token()
|
||||
if not success:
|
||||
logger.warning(
|
||||
"Не удалось синхронизировать бутстрап токен веб-API после обновления настроек",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_setting_summary(cls, key: str) -> Dict[str, Any]:
|
||||
definition = cls.get_definition(key)
|
||||
current = cls.get_current_value(key)
|
||||
original = cls.get_original_value(key)
|
||||
has_override = cls.has_override(key)
|
||||
|
||||
return {
|
||||
"key": key,
|
||||
"name": definition.display_name,
|
||||
"current": cls.format_value(current),
|
||||
"original": cls.format_value(original),
|
||||
"type": definition.type_label,
|
||||
"category_key": definition.category_key,
|
||||
"category_label": definition.category_label,
|
||||
"has_override": has_override,
|
||||
}
|
||||
|
||||
|
||||
bot_configuration_service = BotConfigurationService
|
||||
|
||||
+246
-24
@@ -1,22 +1,26 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
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.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
|
||||
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
||||
TransactionType
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -139,20 +143,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)
|
||||
|
||||
@@ -177,7 +193,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,
|
||||
@@ -192,38 +311,82 @@ class UserService:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
|
||||
# Сохраняем старый баланс для уведомления
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
|
||||
if amount_kopeks > 0:
|
||||
await add_user_balance(db, user, amount_kopeks, description=description)
|
||||
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
|
||||
success = True
|
||||
else:
|
||||
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
abs(amount_kopeks),
|
||||
description,
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽")
|
||||
|
||||
|
||||
# Отправляем уведомление пользователю, если операция прошла успешно
|
||||
if success and bot:
|
||||
# Обновляем пользователя для получения нового баланса
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
# Получаем имя администратора
|
||||
if not admin_name:
|
||||
admin_user = await get_user_by_id(db, admin_id)
|
||||
admin_name = admin_user.full_name if admin_user else f"Админ #{admin_id}"
|
||||
|
||||
|
||||
# Отправляем уведомление (не блокируем операцию если не удалось отправить)
|
||||
await self._send_balance_notification(bot, user, amount_kopeks, admin_name)
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def update_user_promo_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> Tuple[bool, Optional[User], Optional[PromoGroup], Optional[PromoGroup]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False, None, None, None
|
||||
|
||||
old_group = user.promo_group
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, promo_group_id)
|
||||
if not promo_group:
|
||||
return False, None, None, old_group
|
||||
|
||||
user.promo_group_id = promo_group.id
|
||||
user.promo_group = promo_group
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"👥 Промогруппа пользователя %s обновлена на '%s'",
|
||||
user.telegram_id,
|
||||
promo_group.name,
|
||||
)
|
||||
|
||||
return True, user, promo_group, old_group
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
|
||||
return False, None, None, None
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -323,7 +486,7 @@ class UserService:
|
||||
|
||||
if delete_mode == "delete":
|
||||
# Удаляем пользователя из панели Remnawave
|
||||
async with remnawave_service.api as api:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
delete_success = await api.delete_user(user.remnawave_uuid)
|
||||
if delete_success:
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} удален из панели")
|
||||
@@ -457,7 +620,7 @@ class UserService:
|
||||
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
|
||||
)
|
||||
cryptobot_payments = cryptobot_result.scalars().all()
|
||||
|
||||
|
||||
if cryptobot_payments:
|
||||
logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей")
|
||||
await db.execute(
|
||||
@@ -472,7 +635,49 @@ class UserService:
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
|
||||
|
||||
|
||||
try:
|
||||
mulenpay_result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
|
||||
)
|
||||
mulenpay_payments = mulenpay_result.scalars().all()
|
||||
|
||||
if mulenpay_payments:
|
||||
logger.info(f"🔄 Удаляем {len(mulenpay_payments)} MulenPay платежей")
|
||||
await db.execute(
|
||||
update(MulenPayPayment)
|
||||
.where(MulenPayPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления MulenPay платежей: {e}")
|
||||
|
||||
try:
|
||||
pal24_result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.user_id == user_id)
|
||||
)
|
||||
pal24_payments = pal24_result.scalars().all()
|
||||
|
||||
if pal24_payments:
|
||||
logger.info(f"🔄 Удаляем {len(pal24_payments)} Pal24 платежей")
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(Pal24Payment).where(Pal24Payment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
@@ -553,7 +758,7 @@ class UserService:
|
||||
select(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
broadcast_history = broadcast_history_result.scalars().all()
|
||||
|
||||
|
||||
if broadcast_history:
|
||||
logger.info(f"🔄 Удаляем {len(broadcast_history)} записей истории рассылок")
|
||||
await db.execute(
|
||||
@@ -562,6 +767,23 @@ class UserService:
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
|
||||
|
||||
try:
|
||||
campaigns_result = await db.execute(
|
||||
select(AdvertisingCampaign).where(AdvertisingCampaign.created_by == user_id)
|
||||
)
|
||||
campaigns = campaigns_result.scalars().all()
|
||||
|
||||
if campaigns:
|
||||
logger.info(f"🔄 Очищаем создателя у {len(campaigns)} рекламных кампаний")
|
||||
await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(AdvertisingCampaign.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления рекламных кампаний: {e}")
|
||||
|
||||
try:
|
||||
if user.subscription:
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud import web_api_token as crud
|
||||
from app.database.models import WebApiToken
|
||||
from app.database.universal_migration import ensure_default_web_api_token
|
||||
from app.utils.security import generate_api_token, hash_api_token
|
||||
|
||||
|
||||
class WebApiTokenService:
|
||||
"""Сервис для управления токенами административного веб-API."""
|
||||
|
||||
def __init__(self):
|
||||
self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or "sha256"
|
||||
|
||||
def hash_token(self, token: str) -> str:
|
||||
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
token_value: str,
|
||||
*,
|
||||
remote_ip: Optional[str] = None,
|
||||
) -> Optional[WebApiToken]:
|
||||
normalized_value = token_value.strip()
|
||||
if not normalized_value:
|
||||
return None
|
||||
|
||||
async def _load_token(value: str) -> Optional[WebApiToken]:
|
||||
token_hash = self.hash_token(value)
|
||||
return await crud.get_token_by_hash(db, token_hash)
|
||||
|
||||
token = await _load_token(normalized_value)
|
||||
|
||||
if not token:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if default_token and secrets.compare_digest(default_token, normalized_value):
|
||||
await ensure_default_web_api_token()
|
||||
token = await _load_token(default_token)
|
||||
|
||||
if not token or not token.is_active:
|
||||
return None
|
||||
|
||||
if token.expires_at and token.expires_at < datetime.utcnow():
|
||||
return None
|
||||
|
||||
token.last_used_at = datetime.utcnow()
|
||||
if remote_ip:
|
||||
token.last_used_ip = remote_ip
|
||||
await db.flush()
|
||||
return token
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
created_by: Optional[str] = None,
|
||||
token_value: Optional[str] = None,
|
||||
) -> Tuple[str, WebApiToken]:
|
||||
plain_token = token_value or generate_api_token()
|
||||
token_hash = self.hash_token(plain_token)
|
||||
|
||||
token = await crud.create_token(
|
||||
db,
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=plain_token[:12],
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
return plain_token, token
|
||||
|
||||
async def revoke_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
|
||||
token.is_active = False
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
async def activate_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
|
||||
token.is_active = True
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
web_api_token_service = WebApiTokenService()
|
||||
+42
-3
@@ -1,6 +1,7 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
class RegistrationStates(StatesGroup):
|
||||
waiting_for_language = State()
|
||||
waiting_for_rules_accept = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
@@ -59,19 +60,35 @@ class AdminStates(StatesGroup):
|
||||
editing_campaign_subscription_traffic = State()
|
||||
editing_campaign_subscription_devices = State()
|
||||
editing_campaign_subscription_servers = State()
|
||||
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
waiting_for_broadcast_media = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
creating_promo_group_name = State()
|
||||
creating_promo_group_traffic_discount = State()
|
||||
creating_promo_group_server_discount = State()
|
||||
creating_promo_group_device_discount = State()
|
||||
creating_promo_group_period_discount = State()
|
||||
creating_promo_group_auto_assign = State()
|
||||
|
||||
editing_promo_group_menu = State()
|
||||
editing_promo_group_name = State()
|
||||
editing_promo_group_traffic_discount = State()
|
||||
editing_promo_group_server_discount = State()
|
||||
editing_promo_group_device_discount = State()
|
||||
editing_promo_group_period_discount = State()
|
||||
editing_promo_group_auto_assign = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
editing_device_price = State()
|
||||
editing_user_devices = State()
|
||||
editing_user_traffic = State()
|
||||
|
||||
|
||||
editing_rules_page = State()
|
||||
|
||||
editing_notification_value = State()
|
||||
|
||||
confirming_sync = State()
|
||||
|
||||
editing_server_name = State()
|
||||
@@ -79,6 +96,7 @@ class AdminStates(StatesGroup):
|
||||
editing_server_country = State()
|
||||
editing_server_limit = State()
|
||||
editing_server_description = State()
|
||||
editing_server_promo_groups = State()
|
||||
|
||||
creating_server_uuid = State()
|
||||
creating_server_name = State()
|
||||
@@ -90,10 +108,31 @@ class AdminStates(StatesGroup):
|
||||
|
||||
# Состояния для отслеживания источника перехода
|
||||
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()
|
||||
|
||||
class TicketStates(StatesGroup):
|
||||
waiting_for_title = State()
|
||||
waiting_for_message = State()
|
||||
waiting_for_reply = State()
|
||||
|
||||
class AdminTicketStates(StatesGroup):
|
||||
waiting_for_reply = State()
|
||||
waiting_for_block_duration = State()
|
||||
|
||||
class SupportSettingsStates(StatesGroup):
|
||||
waiting_for_desc = State()
|
||||
|
||||
|
||||
class BotConfigStates(StatesGroup):
|
||||
waiting_for_value = State()
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
confirming_autopay_toggle = State()
|
||||
|
||||
+16
-1
@@ -67,13 +67,28 @@ class CacheService:
|
||||
async def delete(self, key: str) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
deleted = await self.redis_client.delete(key)
|
||||
return deleted > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления из кеша {key}: {e}")
|
||||
return False
|
||||
|
||||
async def delete_pattern(self, pattern: str) -> int:
|
||||
if not self._connected:
|
||||
return 0
|
||||
|
||||
try:
|
||||
keys = await self.redis_client.keys(pattern)
|
||||
if not keys:
|
||||
return 0
|
||||
|
||||
deleted = await self.redis_client.delete(*keys)
|
||||
return int(deleted)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления ключей по шаблону {pattern}: {e}")
|
||||
return 0
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
if not self._connected:
|
||||
|
||||
@@ -8,22 +8,28 @@ from app.states import RegistrationStates
|
||||
|
||||
def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool:
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_language.state,
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
registration_callbacks = [
|
||||
"rules_accept",
|
||||
"rules_decline",
|
||||
"rules_accept",
|
||||
"rules_decline",
|
||||
"referral_skip"
|
||||
]
|
||||
|
||||
language_select_prefix = "language_select:"
|
||||
|
||||
if current_state in registration_states:
|
||||
return True
|
||||
|
||||
if (isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and event.data in registration_callbacks):
|
||||
if (isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (
|
||||
event.data in registration_callbacks
|
||||
or event.data.startswith(language_select_prefix)
|
||||
)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
+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,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from aiogram.types import Message, FSInputFile, InputMediaPhoto
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.config import settings
|
||||
|
||||
@@ -15,13 +16,40 @@ _original_edit_text = Message.edit_text
|
||||
|
||||
|
||||
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
# Если caption слишком длинный для фото — отправим как текст
|
||||
try:
|
||||
if text is not None and len(text) > 900:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
if LOGO_PATH.exists():
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
try:
|
||||
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
except Exception:
|
||||
# Фоллбек, если Telegram ругается на caption: отправим как текст
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
|
||||
|
||||
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:
|
||||
# Если caption потенциально слишком длинный — отправим как текст вместо caption
|
||||
try:
|
||||
if text is not None and len(text) > 900:
|
||||
try:
|
||||
await self.delete()
|
||||
except Exception:
|
||||
pass
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self):
|
||||
@@ -32,8 +60,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
media = self.photo[-1].file_id
|
||||
media_kwargs = {"media": media, "caption": text}
|
||||
if "parse_mode" in kwargs:
|
||||
media_kwargs["parse_mode"] = kwargs.pop("parse_mode")
|
||||
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
|
||||
_pm = 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:
|
||||
# Фоллбек: удалим и отправим обычный текст без фото
|
||||
try:
|
||||
await self.delete()
|
||||
except Exception:
|
||||
pass
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -30,17 +30,35 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
methods.append({
|
||||
"id": "tribute",
|
||||
"name": "Банковская карта",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через Tribute",
|
||||
"callback": "topup_tribute"
|
||||
})
|
||||
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
methods.append({
|
||||
"id": "mulenpay",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через Mulen Pay",
|
||||
"callback": "topup_mulenpay"
|
||||
})
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
methods.append({
|
||||
"id": "pal24",
|
||||
"name": "СБП",
|
||||
"icon": "🏦",
|
||||
"description": "через PayPalych",
|
||||
"callback": "topup_pal24"
|
||||
})
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
methods.append({
|
||||
"id": "cryptobot",
|
||||
"name": "Криптовалюта",
|
||||
"icon": "🪙",
|
||||
"icon": "🪙",
|
||||
"description": "через CryptoBot",
|
||||
"callback": "topup_cryptobot"
|
||||
})
|
||||
@@ -112,6 +130,10 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_yookassa_enabled()
|
||||
elif method_id == "tribute":
|
||||
return settings.TRIBUTE_ENABLED
|
||||
elif method_id == "mulenpay":
|
||||
return settings.is_mulenpay_enabled()
|
||||
elif method_id == "pal24":
|
||||
return settings.is_pal24_enabled()
|
||||
elif method_id == "cryptobot":
|
||||
return settings.is_cryptobot_enabled()
|
||||
elif method_id == "support":
|
||||
@@ -127,6 +149,8 @@ def get_payment_method_status() -> Dict[str, bool]:
|
||||
"stars": settings.TELEGRAM_STARS_ENABLED,
|
||||
"yookassa": settings.is_yookassa_enabled(),
|
||||
"tribute": settings.TRIBUTE_ENABLED,
|
||||
"mulenpay": settings.is_mulenpay_enabled(),
|
||||
"pal24": settings.is_pal24_enabled(),
|
||||
"cryptobot": settings.is_cryptobot_enabled(),
|
||||
"support": True
|
||||
}
|
||||
@@ -139,9 +163,13 @@ def get_enabled_payment_methods_count() -> int:
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
count += 1
|
||||
if settings.is_yookassa_enabled():
|
||||
count += 1
|
||||
count += 1
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
count += 1
|
||||
if settings.is_mulenpay_enabled():
|
||||
count += 1
|
||||
if settings.is_pal24_enabled():
|
||||
count += 1
|
||||
if settings.is_cryptobot_enabled():
|
||||
count += 1
|
||||
return count
|
||||
@@ -23,6 +23,7 @@ async def edit_or_answer_photo(
|
||||
keyboard: types.InlineKeyboardMarkup,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> None:
|
||||
# Если режим логотипа выключен — работаем текстом
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
@@ -47,17 +48,44 @@ async def edit_or_answer_photo(
|
||||
)
|
||||
return
|
||||
|
||||
# Если текст слишком длинный для caption — отправим как текст
|
||||
if caption and len(caption) > 1000:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
return
|
||||
|
||||
media = _resolve_media(callback.message)
|
||||
try:
|
||||
await callback.message.edit_media(
|
||||
InputMediaPhoto(media=media, caption=caption, parse_mode=parse_mode),
|
||||
InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or "HTML")),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
FSInputFile(LOGO_PATH),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
# Фоллбек: если не удалось обновить фото — отправим текст, чтобы не упасть на лимите caption
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# Отправим как фото с логотипом
|
||||
await callback.message.answer_photo(
|
||||
photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=(parse_mode or "HTML"),
|
||||
)
|
||||
except Exception:
|
||||
# Последний фоллбек — обычный текст
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=(parse_mode or "HTML"),
|
||||
)
|
||||
|
||||
@@ -28,8 +28,8 @@ def calculate_period_multiplier(period_days: int) -> Tuple[int, float]:
|
||||
|
||||
|
||||
def calculate_prorated_price(
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
min_charge_months: int = 1
|
||||
) -> Tuple[int, int]:
|
||||
months_remaining = get_remaining_months(end_date)
|
||||
@@ -42,10 +42,38 @@ def calculate_prorated_price(
|
||||
return total_price, months_to_charge
|
||||
|
||||
|
||||
def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
|
||||
if amount <= 0 or percent <= 0:
|
||||
return amount, 0
|
||||
|
||||
clamped_percent = max(0, min(100, percent))
|
||||
discount_value = amount * clamped_percent // 100
|
||||
discounted_amount = amount - discount_value
|
||||
|
||||
# Round the discounted price up to the nearest full ruble (100 kopeks)
|
||||
# to avoid undercharging users because of fractional kopeks.
|
||||
if discount_value >= 100 and discounted_amount % 100:
|
||||
discounted_amount += 100 - (discounted_amount % 100)
|
||||
discounted_amount = min(discounted_amount, amount)
|
||||
discount_value = amount - discounted_amount
|
||||
|
||||
logger.debug(
|
||||
"Применена скидка %s%%: %s → %s (скидка %s)",
|
||||
clamped_percent,
|
||||
amount,
|
||||
discounted_amount,
|
||||
discount_value,
|
||||
)
|
||||
|
||||
return discounted_amount, discount_value
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
if language == "ru":
|
||||
if days == 14:
|
||||
return "14 дней"
|
||||
if days == 30:
|
||||
return "1 месяц"
|
||||
elif days == 60:
|
||||
@@ -59,7 +87,9 @@ def format_period_description(days: int, language: str = "ru") -> str:
|
||||
else:
|
||||
month_word = "месяц" if months == 1 else ("месяца" if 2 <= months <= 4 else "месяцев")
|
||||
return f"{days} дней ({months} {month_word})"
|
||||
else:
|
||||
else:
|
||||
if days == 14:
|
||||
return "14 days"
|
||||
month_word = "month" if months == 1 else "months"
|
||||
return f"{days} days ({months} {month_word})"
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Утилиты безопасности и генерации ключей."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import Literal
|
||||
|
||||
|
||||
HashAlgorithm = Literal["sha256", "sha384", "sha512"]
|
||||
|
||||
|
||||
def hash_api_token(token: str, algorithm: HashAlgorithm = "sha256") -> str:
|
||||
"""Возвращает хеш токена в формате hex."""
|
||||
normalized = (algorithm or "sha256").lower()
|
||||
if normalized not in {"sha256", "sha384", "sha512"}:
|
||||
raise ValueError(f"Unsupported hash algorithm: {algorithm}")
|
||||
|
||||
digest = getattr(hashlib, normalized)
|
||||
return digest(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_api_token(length: int = 48) -> str:
|
||||
"""Генерирует криптографически стойкий токен."""
|
||||
length = max(24, min(length, 128))
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
|
||||
__all__ = ["hash_api_token", "generate_api_token", "HashAlgorithm"]
|
||||
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, delete
|
||||
from urllib.parse import quote, urlparse, urlunparse
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.models import Subscription, User
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,5 +95,64 @@ async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int:
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок")
|
||||
|
||||
|
||||
return total_deleted
|
||||
|
||||
|
||||
def get_display_subscription_link(subscription: Optional[Subscription]) -> Optional[str]:
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
base_link = getattr(subscription, "subscription_url", None)
|
||||
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
crypto_link = getattr(subscription, "subscription_crypto_link", None)
|
||||
return crypto_link or base_link
|
||||
|
||||
return base_link
|
||||
|
||||
|
||||
def get_happ_cryptolink_redirect_link(subscription_link: Optional[str]) -> Optional[str]:
|
||||
if not subscription_link:
|
||||
return None
|
||||
|
||||
template = settings.get_happ_cryptolink_redirect_template()
|
||||
if not template:
|
||||
return None
|
||||
|
||||
encoded_link = quote(subscription_link, safe="")
|
||||
replacements = {
|
||||
"{subscription_link}": encoded_link,
|
||||
"{link}": encoded_link,
|
||||
"{subscription_link_raw}": subscription_link,
|
||||
"{link_raw}": subscription_link,
|
||||
}
|
||||
|
||||
replaced = False
|
||||
for placeholder, value in replacements.items():
|
||||
if placeholder in template:
|
||||
template = template.replace(placeholder, value)
|
||||
replaced = True
|
||||
|
||||
if replaced:
|
||||
return template
|
||||
|
||||
if template.endswith(("=", "?", "&")):
|
||||
return f"{template}{encoded_link}"
|
||||
|
||||
return f"{template}{encoded_link}"
|
||||
|
||||
|
||||
def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) -> Optional[str]:
|
||||
if not subscription_link:
|
||||
return None
|
||||
|
||||
parsed_link = urlparse(subscription_link)
|
||||
|
||||
if parsed_link.scheme.lower() == "happ":
|
||||
return subscription_link
|
||||
|
||||
if not parsed_link.scheme:
|
||||
return subscription_link
|
||||
|
||||
return urlunparse(parsed_link._replace(scheme="happ"))
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -143,6 +143,23 @@ def sanitize_html(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def sanitize_telegram_name(name: Optional[str]) -> Optional[str]:
|
||||
"""Санитизация Telegram-имени для безопасной вставки в HTML и хранения.
|
||||
Заменяет угловые скобки и амперсанд на безопасные визуальные аналоги.
|
||||
"""
|
||||
if not name:
|
||||
return name
|
||||
try:
|
||||
return (
|
||||
name.replace('<', '‹')
|
||||
.replace('>', '›')
|
||||
.replace('&', '&')
|
||||
.strip()
|
||||
)
|
||||
except Exception:
|
||||
return name
|
||||
|
||||
|
||||
def validate_device_count(count: Union[str, int]) -> Optional[int]:
|
||||
try:
|
||||
count_int = int(count)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Пакет административного веб-API."""
|
||||
from .app import create_web_api_app
|
||||
from .server import WebAPIServer
|
||||
|
||||
__all__ = ["create_web_api_app", "WebAPIServer"]
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from .middleware import RequestLoggingMiddleware
|
||||
from .routes import (
|
||||
broadcasts,
|
||||
backups,
|
||||
campaigns,
|
||||
config,
|
||||
health,
|
||||
promocodes,
|
||||
miniapp,
|
||||
promo_groups,
|
||||
remnawave,
|
||||
stats,
|
||||
subscriptions,
|
||||
tickets,
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
)
|
||||
|
||||
|
||||
OPENAPI_TAGS = [
|
||||
{
|
||||
"name": "health",
|
||||
"description": "Мониторинг состояния административного API и связанных сервисов.",
|
||||
},
|
||||
{
|
||||
"name": "stats",
|
||||
"description": "Сводные показатели по пользователям, подпискам и платежам.",
|
||||
},
|
||||
{
|
||||
"name": "settings",
|
||||
"description": "Получение и изменение конфигурации бота из административной панели.",
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
"description": "Управление пользователями, балансом и статусами подписок.",
|
||||
},
|
||||
{
|
||||
"name": "subscriptions",
|
||||
"description": "Создание, продление и настройка подписок бота.",
|
||||
},
|
||||
{
|
||||
"name": "support",
|
||||
"description": "Работа с тикетами поддержки, приоритетами и ограничениями на ответы.",
|
||||
},
|
||||
{
|
||||
"name": "transactions",
|
||||
"description": "История финансовых операций и пополнений баланса.",
|
||||
},
|
||||
{
|
||||
"name": "promo-groups",
|
||||
"description": "Создание и управление промо-группами и их участниками.",
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": "Управление токенами доступа к административному API.",
|
||||
},
|
||||
{
|
||||
"name": "remnawave",
|
||||
"description": (
|
||||
"Интеграция с RemnaWave: статус панели, управление нодами, сквадами и синхронизацией "
|
||||
"данных между ботом и панелью."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "miniapp",
|
||||
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def create_web_api_app() -> FastAPI:
|
||||
docs_config = settings.get_web_api_docs_config()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.WEB_API_TITLE,
|
||||
version=settings.WEB_API_VERSION,
|
||||
docs_url=docs_config.get("docs_url"),
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
openapi_tags=OPENAPI_TAGS,
|
||||
swagger_ui_parameters={"persistAuthorization": True},
|
||||
)
|
||||
|
||||
allowed_origins = settings.get_web_api_allowed_origins()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"] if allowed_origins == ["*"] else allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
if settings.WEB_API_REQUEST_LOGGING:
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||
app.include_router(config.router, prefix="/settings", tags=["settings"])
|
||||
app.include_router(users.router, prefix="/users", tags=["users"])
|
||||
app.include_router(subscriptions.router, prefix="/subscriptions", tags=["subscriptions"])
|
||||
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(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
|
||||
@@ -0,0 +1 @@
|
||||
"""Background utilities for Web API."""
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.services.backup_service import backup_service
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BackupTaskState:
|
||||
task_id: str
|
||||
status: str = "queued"
|
||||
message: Optional[str] = None
|
||||
file_path: Optional[str] = None
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class BackupTaskManager:
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[str, BackupTaskState] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def enqueue(self, *, created_by: Optional[int]) -> BackupTaskState:
|
||||
task_id = uuid.uuid4().hex
|
||||
state = BackupTaskState(task_id=task_id, created_by=created_by)
|
||||
|
||||
async with self._lock:
|
||||
self._tasks[task_id] = state
|
||||
|
||||
asyncio.create_task(self._run_task(state))
|
||||
return state
|
||||
|
||||
async def _run_task(self, state: BackupTaskState) -> None:
|
||||
state.status = "running"
|
||||
state.updated_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
success, message, file_path = await backup_service.create_backup(
|
||||
created_by=state.created_by
|
||||
)
|
||||
state.message = message
|
||||
state.file_path = file_path
|
||||
state.status = "completed" if success else "failed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
state.status = "failed"
|
||||
state.message = f"Unexpected error: {exc}"
|
||||
finally:
|
||||
state.updated_at = datetime.utcnow()
|
||||
|
||||
async def get(self, task_id: str) -> Optional[BackupTaskState]:
|
||||
async with self._lock:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
async def list(self, *, active_only: bool = False) -> list[BackupTaskState]:
|
||||
async with self._lock:
|
||||
states = list(self._tasks.values())
|
||||
|
||||
if active_only:
|
||||
return [
|
||||
state
|
||||
for state in states
|
||||
if state.status in {"queued", "running"}
|
||||
]
|
||||
|
||||
return states
|
||||
|
||||
|
||||
backup_task_manager = BackupTaskManager()
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Security, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import WebApiToken
|
||||
from app.services.web_api_token_service import web_api_token_service
|
||||
|
||||
|
||||
api_key_header_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def require_api_token(
|
||||
request: Request,
|
||||
api_key_header: str | None = Security(api_key_header_scheme),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> WebApiToken:
|
||||
api_key = api_key_header
|
||||
|
||||
if not api_key:
|
||||
authorization = request.headers.get("Authorization")
|
||||
if authorization:
|
||||
scheme, _, credentials = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and credentials:
|
||||
api_key = credentials
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing API key",
|
||||
)
|
||||
|
||||
token = await web_api_token_service.authenticate(
|
||||
db,
|
||||
api_key,
|
||||
remote_ip=request.client.host if request.client else None,
|
||||
)
|
||||
|
||||
if not token:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired API key",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return token
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user