Compare commits
714 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef70f176dd | |||
| 7a111bae6c | |||
| 3132b32545 | |||
| 10da3595b9 | |||
| f0f3f3508f | |||
| 60b85911e1 | |||
| e78d75a440 | |||
| de57db14ea | |||
| b85d5083a9 | |||
| 862fcc53c4 | |||
| 42f1757508 | |||
| 50279979ce | |||
| ec00fe12a0 | |||
| 0806ed9f1e | |||
| 11b886d6ca | |||
| 291695395f | |||
| f787dfef04 | |||
| edc6b766cb | |||
| da2d2c826a | |||
| 0ad1f58fd4 | |||
| 9f398dbbe5 | |||
| 3a33928c9e | |||
| cf77b38fe7 | |||
| 3ab2dc0484 | |||
| 27b5f70712 | |||
| 05e47e3a1c | |||
| e85a6dd9e0 | |||
| 595c7ce5bb | |||
| 46e3433b55 | |||
| f7577c1acc | |||
| 2e57522a58 | |||
| a3ca5725a6 | |||
| 3a09240019 | |||
| b3ad09f0a1 | |||
| de515862f4 | |||
| c49e8d6c18 | |||
| decafb1d0e | |||
| 8f19a5e205 | |||
| fc75009e9b | |||
| c753f2f67e | |||
| 5a260c00d0 | |||
| 6f690a672e | |||
| 3b2c560947 | |||
| fb1641c5ba | |||
| 91615ec4b9 | |||
| fe98d4391f | |||
| 76f8ccdd85 | |||
| 45bc3bc15c | |||
| 0975fdea4d | |||
| 61bf48b7fa | |||
| 0ff4dd4321 | |||
| e0edc6bcdc | |||
| 14e58421e4 | |||
| b24ba2e75f | |||
| d9be872581 | |||
| 30ab648784 | |||
| b730de4175 | |||
| 6bfb484a9b | |||
| 0e6f6d0908 | |||
| 90a495e942 | |||
| 0182e190a8 | |||
| 30274dce20 | |||
| 0098cc724f | |||
| d9cf4008fe | |||
| ca83cf5349 | |||
| bf5e267822 | |||
| b51a0c04bc | |||
| 42e19de19c | |||
| 56c5e04c76 | |||
| 556f9dbd45 | |||
| 562fe9dccb | |||
| 960063fdca | |||
| 4a4e7406c3 | |||
| fca88c5e08 | |||
| 55dcb61798 | |||
| 3d9cc65336 | |||
| 61c69e6827 | |||
| 246c9d3935 | |||
| 7f6b0f4190 | |||
| 9e495e9f64 | |||
| 58bc8e97ee | |||
| f491a6bf00 | |||
| 8abcd0707d | |||
| b4ac9cd2bc | |||
| b18759b69f | |||
| d327ad9379 | |||
| cfe86bd8d3 | |||
| 5cb4479a70 | |||
| 64664fa30f | |||
| 15ed087142 | |||
| be6d358ed0 | |||
| dd77ecae9d | |||
| edfb325ccd | |||
| a4a6685dbb | |||
| ae9d32ceb4 | |||
| fa12448068 | |||
| a72b7ce3c8 | |||
| 1bf150f45a | |||
| e0633c409d | |||
| 2621b2f8be | |||
| ba1394c35f | |||
| 1072b64af8 | |||
| d3584d52e8 | |||
| f32b9242ba | |||
| 516a1f3e52 | |||
| 57906b1df9 | |||
| 83865dd0b9 | |||
| 69553d2551 | |||
| 02a8b932a3 | |||
| b27624021f | |||
| 4fde44eefd | |||
| 37f5977efd | |||
| a9cfbb4a4d | |||
| c7beec1df0 | |||
| 172c3426b9 | |||
| a616987eab | |||
| 0742d7cdc9 | |||
| f10a84399b | |||
| 6d2eb30704 | |||
| 618a5153f6 | |||
| 292378ebbc | |||
| 645cca088a | |||
| 4159689a4e | |||
| dd2ec37bec | |||
| 125131c8e6 | |||
| 3b895b73f9 | |||
| 39c915acc4 | |||
| 8f08489939 | |||
| baf69495d6 | |||
| 7e9c1bd784 | |||
| 509e7ffe14 | |||
| e4547b3ca0 | |||
| 5d638dbba9 | |||
| d0390bb432 | |||
| 66eaadd628 | |||
| 8a5b5599cc | |||
| a7935f3e88 | |||
| bbb89633c3 | |||
| 40a690b4bd | |||
| 4f06a183be | |||
| 6701a63a0a | |||
| 905e1f1861 | |||
| fe91fc1c29 | |||
| df8b85a179 | |||
| 8d08b23d0f | |||
| ce087beea6 | |||
| e63d2158c3 | |||
| eb64d8ed3a | |||
| fc4e17d995 | |||
| f22fd67b44 | |||
| e9ba19011a | |||
| 6569e45d9c | |||
| bc12bf47d6 | |||
| e731ccc055 | |||
| c648e78e34 | |||
| ef073bd307 | |||
| dde44a8afd | |||
| 0981aa6efa | |||
| 2b71e1701c | |||
| 2bd5c3e061 | |||
| 8f39c69f22 | |||
| b4301265ec | |||
| 7d793eff6a | |||
| 48e1485cac | |||
| f548f186de | |||
| 97ae7b6b1b | |||
| ac06b039a9 | |||
| 8514f8604a | |||
| a22b04a91e | |||
| 11f5289f2d | |||
| c72886d45a | |||
| fbfd59ca83 | |||
| 1737791659 | |||
| 3a8112b4ec | |||
| cfdb7b6bdd | |||
| 223a112d88 | |||
| b72ad3e090 | |||
| e81b555e43 | |||
| 8214775ded | |||
| 3bb5727089 | |||
| 39677eb82e | |||
| c56cdf2383 | |||
| a40c4dd7e9 | |||
| a09fd825bf | |||
| 03cf74656e | |||
| 98a301c1b5 | |||
| 1c865e3ae8 | |||
| 01388442b7 | |||
| 251a940be2 | |||
| fc725ba9e1 | |||
| 9f7e73ae2e | |||
| d576801676 | |||
| 8b82d56635 | |||
| b165fafc4d | |||
| bb9a0b305c | |||
| 99b219a307 | |||
| ad2a9b9d14 | |||
| e03bf2a393 | |||
| b6ead34e6c | |||
| cb180d9c0e | |||
| 791e26f56a | |||
| b0403dab83 | |||
| e6c3d45991 | |||
| d43597ab2a | |||
| b2bf5024a0 | |||
| 001f35ed89 | |||
| 9def85e8e2 | |||
| ed92e0cd8e | |||
| 336cfe440a | |||
| 3c312e089b | |||
| d54e42612e | |||
| 233520a656 | |||
| aeff150feb | |||
| f0d67e2428 | |||
| 72df1d91d5 | |||
| 26811cdcff | |||
| bc26d8352b | |||
| 4fe2e029bc | |||
| f54bf16ef4 | |||
| 7dfa5d5a6d | |||
| ac1f6120ab | |||
| 26c9896b51 | |||
| 3b7323c6f4 | |||
| 956936185a | |||
| 435c2d98f1 | |||
| 7f24bd7c8c | |||
| b15781ad3b | |||
| c598d66ecc | |||
| ffc969daaa | |||
| 116201cca3 | |||
| a7592836da | |||
| 734c3679ce | |||
| d350b627fc | |||
| 4fe483da56 | |||
| 8b4e1c830b | |||
| ebfb653d8b | |||
| 8b4f1672dd | |||
| e48a3f178e | |||
| a72ed1c2a1 | |||
| 15db2d975f | |||
| 99ff5f8a9d | |||
| 742093e68c | |||
| a72f6caf80 | |||
| 605d33053d | |||
| 7a2e83f3d1 | |||
| e7fc1b8402 | |||
| 5fcdcc5878 | |||
| 4212fc0be3 | |||
| df4ce40739 | |||
| b761630505 | |||
| d1c3358dbf | |||
| 1d1fc7dbfb | |||
| 25aaa703a6 | |||
| 46f3ef23ed | |||
| 80517f0c53 | |||
| 66d1f96e32 | |||
| a93c5808c0 | |||
| f80818c18a | |||
| 6199716c09 | |||
| ab131ed399 | |||
| 43203bfeb3 | |||
| 4873a26880 | |||
| 974b049ee0 | |||
| 3656ec5df5 | |||
| ef4d343e16 | |||
| 71171b24b3 | |||
| 5ce9fa9fac | |||
| 85a702b00a | |||
| aab8727de7 | |||
| 813bb45cf9 | |||
| 3d731e6b06 | |||
| 8e6174ea13 | |||
| 7a5a1fa8b4 | |||
| 1a4c8eff19 | |||
| b345a4ce25 | |||
| d8c37349ca | |||
| 4df3b2ea8f | |||
| 8bc683e03a | |||
| c176a9abf2 | |||
| 72553e5f8b | |||
| de09965876 | |||
| 7eb14ad036 | |||
| 0e0c917fdd | |||
| 7eb58d1add | |||
| 405cba645a | |||
| 359b7c807c | |||
| dbed5d9e5d | |||
| f999a31664 | |||
| 96c795c789 | |||
| 874c5d8017 | |||
| 2c76751c63 | |||
| 483b99e557 | |||
| b653c45320 | |||
| 3f0f9c3f4b | |||
| d899dc7d86 | |||
| 21567088a1 | |||
| 5425210cb0 | |||
| 0484d69538 | |||
| ac5c764daa | |||
| 15df6b802c | |||
| a1e1267239 | |||
| 5fcaa73d20 | |||
| c57d273c7e | |||
| 51a1db0680 | |||
| a3d46cd293 | |||
| 18ec1c1f5f | |||
| a9d4bea9de | |||
| 10b05f95ab | |||
| b5be8e5237 | |||
| 4117dcb97f | |||
| 0cb422d33e | |||
| da1ed55fd7 | |||
| bb17f1c456 | |||
| b2f88e75d5 | |||
| 767e7b653e | |||
| 2a7969716a | |||
| 3e10bceb43 | |||
| 6f293615bb | |||
| 69ce111281 | |||
| 85c8ad018a | |||
| 385ebd1d39 | |||
| 4227a8613e | |||
| 2dc8947b00 | |||
| 851f4dc58c | |||
| 366fc28ec5 | |||
| 277d690686 | |||
| 077584fb57 | |||
| 1bd05de773 | |||
| 1ae84a73dc | |||
| f5dad62ab0 | |||
| 2d90b9783a | |||
| dd174bf6a5 | |||
| fcd20b3aaa | |||
| d97a71d96a | |||
| e7b631a991 | |||
| 948e36478b | |||
| ec52741cf8 | |||
| 08c3a54e6d | |||
| 75af19ab83 | |||
| 13c89c1d6f | |||
| c091cb95c8 | |||
| 65aa0a7c08 | |||
| 080c019684 | |||
| 0e1962c291 | |||
| e0aea226a4 | |||
| 687ea7d1d6 | |||
| b7146268c2 | |||
| 0952059c60 | |||
| bbf5575b49 | |||
| 6b8e913f91 | |||
| 51e5f1c715 | |||
| 910f48bff0 | |||
| 62325d7a21 | |||
| dc07de9957 | |||
| d65524d349 | |||
| fd989d0186 | |||
| 390874565e | |||
| ae64dc48d2 | |||
| 8a68a0d4b1 | |||
| 82cdd4442e | |||
| 112ce528d9 | |||
| 950f84d012 | |||
| 025a57a325 | |||
| 9d374f3ba1 | |||
| d4aec3b8aa | |||
| ffe8f70946 | |||
| febf9ec669 | |||
| 6bff990d59 | |||
| d50afd94eb | |||
| d43bab9c88 | |||
| b6b63cd82d | |||
| d7534e098c | |||
| 1af55ec86e | |||
| 3b72707fd9 | |||
| 55ee06402e | |||
| bd19b570e5 | |||
| d0735aad31 | |||
| 73594b9088 | |||
| 6075ed5410 | |||
| 07ff14c6b1 | |||
| 36ac94468a | |||
| 3c3bd139e3 | |||
| 82b2127e55 | |||
| 5f2bb415de | |||
| c2a6dfb3b4 | |||
| 04bcc53012 | |||
| d9330bfb7a | |||
| 938f96bef8 | |||
| 4ea0868f6a | |||
| d032b9fcb1 | |||
| a0efbb105e | |||
| 6cfd239217 | |||
| 8378a2c555 | |||
| fd8ced4f1a | |||
| 261b5b8d14 | |||
| 0fb684544f | |||
| 22f9c9a48d | |||
| b6c3acfa13 | |||
| 8b94ac7e1d | |||
| a676e2818a | |||
| b5c2ad4c07 | |||
| d3100f6996 | |||
| 3a13c5225b | |||
| eab3626848 | |||
| 7a34a9d370 | |||
| 7045412fe6 | |||
| 7027fa91c3 | |||
| 8e9f7f91ef | |||
| e6923a0d0a | |||
| 3250976709 | |||
| cb5b731e92 | |||
| 34a7c0dfc6 | |||
| 009b6bf71d | |||
| d01da00809 | |||
| a76ca27f22 | |||
| 1a73b13d50 | |||
| 073af762c5 | |||
| b5c8b4218c | |||
| 00605a37ab | |||
| 0f61df80e2 | |||
| d1777b48ea | |||
| fae4036670 | |||
| 57a32ea52e | |||
| 5412ad4202 | |||
| ab9bc8b9e1 | |||
| 2c4090b63e | |||
| 61287d5dbc | |||
| 0cc37c1a9e | |||
| c877b327a5 | |||
| a27c9a7463 | |||
| 16c99bed06 | |||
| 045bc32798 | |||
| f1dbd7c784 | |||
| cb9c673af1 | |||
| 53ed64afa3 | |||
| 23d313e12d | |||
| 02313fc924 | |||
| 860937591d | |||
| 9388f1c81a | |||
| d7c4f439b9 | |||
| 4273d2354c | |||
| ee1c215278 | |||
| da2260f55c | |||
| bdeff1dbad | |||
| bd421f23da | |||
| c63b439722 | |||
| b501e937de | |||
| afa65e0fbd | |||
| 16945492e8 | |||
| 3c879914ca | |||
| d4f8d9b94d | |||
| f44e6aca6f | |||
| af273c5762 | |||
| c24ba07751 | |||
| 1fb1dadd9d | |||
| 83536d7f3d | |||
| c2be32783f | |||
| 6654608aba | |||
| 6f29265913 | |||
| 95b42d6e9b | |||
| e76975388e | |||
| 43ebb11793 | |||
| e6d373d9bd | |||
| bea83a3635 | |||
| ba73236ca1 | |||
| 4120d516b4 | |||
| 09a46fccad | |||
| 7b27f39f74 | |||
| e4b1754e8b | |||
| 3114bf59de | |||
| 30b87e13c6 | |||
| 45d2ca6e25 | |||
| 42e3fae980 | |||
| 07c4ab264c | |||
| bdaa3b5875 | |||
| 42696d0564 | |||
| 3c0c5a3494 | |||
| 743c3dabee | |||
| 3b8708f8e6 | |||
| 977042e66c | |||
| a91f42e9f0 | |||
| c4d5388081 | |||
| f73a0fcfe8 | |||
| 7ff6e42f5b | |||
| cdd1e3f107 | |||
| 6c6ade4f7f | |||
| 30ad692bc2 | |||
| 24b3ea4881 | |||
| 560689d9ee | |||
| aa763403c6 | |||
| 96c0c3562d | |||
| e48eaf584c | |||
| 790cd6bab3 | |||
| 55ae2bd91c | |||
| d431931501 | |||
| 425d15c850 | |||
| 58affda938 | |||
| c4eb2a60de | |||
| 46dca8c095 | |||
| b752278398 | |||
| 6a659a2131 | |||
| 019397b0ea | |||
| 947a400909 | |||
| 7f52beb2a2 | |||
| 283d42af86 | |||
| d36d1e3041 | |||
| a83bc25bd0 | |||
| 3555ada3b5 | |||
| 1cb93d9688 | |||
| 6f9fc5e071 | |||
| 96d466328c | |||
| 846e7844db | |||
| 4e0b0837d9 | |||
| 8ba5cda095 | |||
| 7ac33bce28 | |||
| d8a0c166ac | |||
| c1858fe925 | |||
| dae8e4c34c | |||
| 3d7c219fd1 | |||
| 03781463e4 | |||
| dcd9074136 | |||
| 7fbd26c8c3 | |||
| 9677a55375 | |||
| eb51cda617 | |||
| 09bdde6f91 | |||
| 784b2ed996 | |||
| 9bee4a2365 | |||
| f8946b99f3 | |||
| a17cbf4448 | |||
| b0422e0c0f | |||
| 9d0bf8b3cf | |||
| 8c5e8ca019 | |||
| 0327adb8da | |||
| 5769fa63a5 | |||
| 1d09006b7c | |||
| e194ab9729 | |||
| 45a6173916 | |||
| b21baba006 | |||
| 45c1b4695c | |||
| 9b2b28423d | |||
| 9d00c2ddc6 | |||
| dc66676663 | |||
| 1d600e2c63 | |||
| 5ce177a7b5 | |||
| d181a333d7 | |||
| 3694cb4ed7 | |||
| 051048e0ae | |||
| 61ee6e42cf | |||
| 5bd618a799 | |||
| 7794a4cc7b | |||
| 6d01d32dc2 | |||
| be71def203 | |||
| 7a2de410bd | |||
| b019e8b32d | |||
| cb4f773eb1 | |||
| 29fe9f4943 | |||
| 63edfa852d | |||
| 66fabb6836 | |||
| f29ea49185 | |||
| 7620a3df92 | |||
| 577bcfdc88 | |||
| 33ec09edd0 | |||
| 7b3c463fa2 | |||
| 0e541af5e1 | |||
| e39822e732 | |||
| c7973e7b58 | |||
| 4941052d57 | |||
| 0022abc6e0 | |||
| acef77b0e8 | |||
| 4ddd580347 | |||
| 42cc4db077 | |||
| 8e3f976441 | |||
| 190d9ae13e | |||
| d6deec1a29 | |||
| e8dcbd09ff | |||
| 382f1eb6f7 | |||
| 2853410ebc | |||
| 2bde6a83ed | |||
| 83b1fa0f0f | |||
| 3a2a05d307 | |||
| 2460766e30 | |||
| 3eb01eaabc | |||
| 35bca4166d | |||
| f2db938fdc | |||
| ff0c68afc6 | |||
| 67babf7566 | |||
| f71788d2d4 | |||
| 5eebdcc85c | |||
| 4b89de9634 | |||
| b81d5b199b | |||
| a21559cd0d | |||
| 6cd3f87318 | |||
| f9e416341f | |||
| 8a2149d2f8 | |||
| e0f6e3b1ef | |||
| 8948f4d302 | |||
| d13ff6830b | |||
| b8aee33e88 | |||
| 17639f3a67 | |||
| cb77b5a21c | |||
| 012ba2049e | |||
| 6acfcc9966 | |||
| f6839c4184 | |||
| 1fbdcb7391 | |||
| 50a65d06d4 | |||
| 20855e5bfc | |||
| 2e4317e457 | |||
| 89dd41ef43 | |||
| 7674b0325b | |||
| 8a5141b58c | |||
| a819be0b5a | |||
| 7de4730822 | |||
| b4815b291e | |||
| 68da3d7d30 | |||
| ca13385dee | |||
| 9216128eb6 | |||
| 3d1fe09540 | |||
| 07b8e04afe | |||
| 01039a969a | |||
| a020db5bc6 | |||
| a91baf2acd | |||
| 1caa2b2ba2 | |||
| a7e9f732a2 | |||
| c8794722a2 | |||
| bbdb3e454e | |||
| fa693494d0 | |||
| 02c38a25ea | |||
| d5d60a8c7a | |||
| 864aaae017 | |||
| 413173270d | |||
| b8eb6606f1 | |||
| 83226c8f3d | |||
| 8a96cddb4f | |||
| e32f3191e4 | |||
| acb5c3c1d7 | |||
| cf13b49b3c | |||
| 2049765969 | |||
| 6b2af1f4a3 | |||
| 00d080cbfb | |||
| d0ce5438a8 | |||
| 05914c4e14 | |||
| a040d03df0 | |||
| a29e0d5fb4 | |||
| ea7697fd66 | |||
| ab86296256 | |||
| 9bb384788f | |||
| 6fc4eb033b | |||
| 8a8abf5b96 | |||
| 01e68bec68 | |||
| 7b6488d166 | |||
| 6c4e4b366a | |||
| 1defa938ba | |||
| efaac8ff76 | |||
| 9246312c68 | |||
| d4bbea616f | |||
| 1be8e1a60f | |||
| 0f1147a226 | |||
| dc7d0fa8d6 | |||
| 1fc4686490 | |||
| 8378020a3e | |||
| bce31ea9a4 | |||
| 5b4337a436 | |||
| ac0953a221 | |||
| bfdb17d653 | |||
| 3159e80897 | |||
| 35d3fb1916 | |||
| 4be51bbe51 | |||
| 2416bd6ad5 | |||
| b3697b1871 | |||
| 483fab8427 | |||
| 95ae04d553 | |||
| a6d8e77f9e | |||
| ae4814d1cb | |||
| fef4f81744 | |||
| 65057348e9 | |||
| 4d32e509e0 | |||
| 8e336e1f7b | |||
| ee7078705b | |||
| 48fe845ff4 | |||
| 99c882110a | |||
| a4f1323a81 | |||
| aad1bb8805 | |||
| d2eb1d35db | |||
| d6fa2a8f3c | |||
| 32b074efd4 | |||
| 787d801722 | |||
| 91c3f2766a | |||
| 911a20a4e3 | |||
| 8e04a0e2b5 | |||
| 6225463af1 | |||
| d4badacd4a | |||
| 63bf04bb70 | |||
| 2529c9bca2 | |||
| bc09d2f610 | |||
| d530d7a296 | |||
| bea0977a86 | |||
| 634b2782d8 | |||
| 0e28cfa326 | |||
| 3d2ca8a02d | |||
| e0e673e7bb | |||
| 0d4cea1e22 | |||
| b643f45c01 | |||
| b5b30a07b8 | |||
| 09ea9b0193 | |||
| cf76ac7893 | |||
| 83b370669e | |||
| 6735cf17cf | |||
| 422686dd2c | |||
| 147dc0343c | |||
| cdff9c4666 | |||
| 25db434e47 | |||
| 5a8aeadcd9 | |||
| bdceb9c3cf | |||
| f43e0daa69 |
@@ -147,6 +147,7 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
DEFAULT_AUTOPAY_ENABLED=true
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.4.5-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.8-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.5-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.8-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.5-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.8-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.4.5-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.8-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.5-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.8-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.5-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.8-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.4.5"
|
||||
ARG VERSION="v2.4.8"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + PayPalych + P2P
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + YooKassa + MulenPay + PayPalych (СБП)
|
||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
|
||||
@@ -696,6 +696,7 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
DEFAULT_AUTOPAY_ENABLED=true
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
@@ -951,105 +952,111 @@ WEBHOOK_PATH=/webhook
|
||||
|
||||
### 👤 **Для пользователей**
|
||||
|
||||
🧭 **Онбординг и доступ**
|
||||
- 🌐 Выбор языка интерфейса, динамическая локализация и настройка кнопки меню.
|
||||
- 📜 Принятие правил, оферты и политики конфиденциальности прямо в боте.
|
||||
- 📡 Проверка подписки на обязательный канал, deeplink-инвайты и UTM-кампании.
|
||||
- 👤 Сбор контактных данных с антиспам-защитой и быстрым восстановлением доступа.
|
||||
|
||||
🛒 **Умная покупка подписок**
|
||||
- 📅 Гибкие периоды (14-360 дней)
|
||||
- 📊 Выбор трафика или фиксированный лимит
|
||||
- 🌍 Автоматический выбор серверов (для одного сквада) или ручной выбор из множества
|
||||
- 📱 Настройка количества устройств и серверов прямо при оформлении
|
||||
- 🧾 Понятный калькулятор стоимости с учетом всех опций
|
||||
- 📅 Гибкие периоды (14–360 дней) и скидки за длительные подписки.
|
||||
- 📊 Выбор трафика: фиксированный лимит, квоты или безлимит.
|
||||
- 🌍 Автоматический выбор сквада или мультивыбор из десятков серверов.
|
||||
- 📱 Настройка количества устройств, стран и доп. услуг прямо в чек-ауте.
|
||||
- 🧾 Динамический калькулятор стоимости, черновики и возвращение к незавершённым заказам.
|
||||
|
||||
🧪 **Тестовая подписка**
|
||||
- Настраиваемый триал-период
|
||||
- Уведомления об истечении и сценарий мягкой конверсии в платную подписку
|
||||
- Приветственное сообщение с предложением активации триала (с возможностью отключения)
|
||||
- Поддержка рекламных кампаний с автоматической выдачей бонусных подписок
|
||||
- Гибко настраиваемый триал и welcome-цепочка.
|
||||
- Уведомления об истечении и автоконверсия в платный тариф.
|
||||
- Автовыдача бонусов за участие в кампаниях и инвайт-линках.
|
||||
- Контроль за обязательной подпиской на канал даже в режиме триала.
|
||||
|
||||
💰 **Удобные платежи**
|
||||
- ⭐ Telegram Stars
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa (включая СБП и онлайн-чек)
|
||||
- 💳 MulenPay
|
||||
- 💳 PayPalych (Pal24)
|
||||
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- Детальная история транзакций и чеков
|
||||
💰 **Платежи и баланс**
|
||||
- ⭐ Telegram Stars, 💳 Tribute, 💳 YooKassa (СБП + онлайн-чек), 💳 MulenPay, 🏦 PayPalych (СБП), 💰 CryptoBot.
|
||||
- 📥 Автогенерация счетов, контроль статусов через вебхуки и уведомления.
|
||||
- 💼 История операций с фильтрами, квитанциями и выгрузкой чеков.
|
||||
- 🔄 Автоплатёж с настройкой дня списания и уведомлениями об ошибках.
|
||||
- 🎁 Реферальные, промо- и кэшбэк-бонусы автоматически начисляются на баланс.
|
||||
|
||||
📱 **Управление подписками**
|
||||
- 📈 Статистика использования в реальном времени (трафик, устройства, серверы)
|
||||
- 🔄 Автопродление с баланса с настройкой дней и мгновенными уведомлениями
|
||||
- 🔄 Управление трафиком и лимитами без участия оператора
|
||||
- 🌍 Переключение серверов Remnawave на лету
|
||||
- 📱 Управление устройствами + сброс HWID
|
||||
- 🧩 Смена языка и персональных настроек
|
||||
- 📈 Реальный трафик, устройства и география серверов прямо в диалоге.
|
||||
- 🌍 Переключение серверов, стран и лимитов без помощи оператора.
|
||||
- 📱 Сброс HWID, повторная выдача конфигов и инструкции подключения.
|
||||
- 🧩 Смена языка, промогруппы и персональных параметров.
|
||||
- 🧾 Просмотр активных услуг, статуса оплаты и сроков действия.
|
||||
|
||||
🎁 **Бонусная система**
|
||||
- 🎫 Промокоды на деньги/дни/длинный триал
|
||||
- 👥 Защищенная реферальная программа с комиссиями и антифродом
|
||||
- 📊 Детальная статистика рефералов и кампаний
|
||||
- 📨 Персональные рассылки и уведомления о новых бонусах
|
||||
🛟 **Поддержка и самообслуживание**
|
||||
- 🎫 Тикеты с приоритетами, вложениями и уведомлениями об ответе.
|
||||
- 📚 FAQ, правила, оферта и политика доступны из меню.
|
||||
- 💬 Быстрые ссылки на живую поддержку или кастомные каналы связи.
|
||||
|
||||
🧩 **Бонусы и рефералы**
|
||||
- 🎫 Промокоды на деньги, дни, устройства, страны и расширенные триалы.
|
||||
- 👥 Реферальная программа с комиссиями, бонусами и антифрод-фильтрами.
|
||||
- 🧾 Подробная аналитика доходов, конверсии и активности приглашённых.
|
||||
- 🔗 Генерация реферальных ссылок и QR-кодов в один тап.
|
||||
|
||||
🪪 **Mini App и инструкции**
|
||||
- 📱 Telegram WebApp с обзором подписки, конфигами и кнопкой «Подключить».
|
||||
- 📥 Библиотека загрузочных ссылок для клиентов Happ и кастомных гайдов.
|
||||
- 🛰️ Web API для получения статуса подписки в мини-приложениях и внешних витринах.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ⚙️ **Для администраторов**
|
||||
|
||||
📊 **Мощная аналитика**
|
||||
- 👥 Детальная статистика пользователей и подписок
|
||||
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot)
|
||||
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
|
||||
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
|
||||
📊 **Аналитика и отчётность**
|
||||
- 📈 Дашборды по пользователям, подпискам, трафику и удержанию.
|
||||
- 💰 Детализация платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot).
|
||||
- 🧮 Продажи по тарифам, устройствам, странам и промогруппам.
|
||||
- 📣 Эффективность кампаний, промокодов, UTM-источников и реферальных потоков.
|
||||
|
||||
👥 **Управление пользователями**
|
||||
- 🔍 Поиск и редактирование профилей
|
||||
- 💰 Управление балансами и ручные начисления
|
||||
- 📱 Изменение лимита устройств (1-X)
|
||||
- 📊 Настройка лимитов трафика (0-10000 ГБ) и подключенных сквадов
|
||||
- 🌍 Мультивыбор серверов и мгновенная синхронизация с панелью
|
||||
- 🔄 Сброс HWID устройств и обновление конфигураций
|
||||
- 👥 Детальная реферальная статистика в профиле пользователя
|
||||
- 🔄 Изменение типа подписки триал/платная и ручное продление
|
||||
- 🚫 Блокировка/разблокировка/удаление аккаунтов
|
||||
- 🔍 Поиск, фильтры и детальные карточки с историей операций.
|
||||
- 💰 Ручное изменение баланса, компенсации и корректировки.
|
||||
- 📱 Изменение лимитов устройств, трафика, стран и подключенных сквадов.
|
||||
- 🔄 Сброс HWID, перегенерация подписки и синхронизация с Remnawave.
|
||||
- 🎯 Назначение промогрупп, тарифов и ручных продлений/обновлений.
|
||||
- 🚫 Блокировки с таймером, полное удаление и аудит действий персонала.
|
||||
|
||||
🎯 **Маркетинг и продажи**
|
||||
- 🎫 Гибкая промо-система (деньги/дни/длинный триал)
|
||||
- 📣 Рекламные кампании с автоматическими бонусами за регистрацию
|
||||
- 📊 Детальная статистика использования промокодов и кампаний
|
||||
- 📨 Рассылки по сегментам с фильтрами по активности, подпискам и языкам
|
||||
🎯 **Продажи, маркетинг и удержание**
|
||||
- 🎫 Промокоды (разовые/многоразовые), бонусные предложения и бесплатные пакеты.
|
||||
- 💳 Промогруппы со скидками на серверы, трафик, устройства и допуслуги.
|
||||
- 📣 Кампании с deeplink-ссылками, UTM и автопродвижением триалов.
|
||||
- 📨 Рассылки по сегментам, кастомные кнопки и медиа, отмена «в полёте».
|
||||
- 🔘 Настройка кнопок главного меню, приветственных экранов и мини-приложений.
|
||||
|
||||
🖥️ **Умный мониторинг**
|
||||
- 💚 Контроль доступности Remnawave панели в реальном времени
|
||||
- 🔄 Автоматическая синхронизация данных и реконнект при ошибках
|
||||
- 🌐 Управление сквадами с актуальным статусом и массовыми действиями
|
||||
- 🚧 Автоматический режим тех. работ с уведомлениями пользователей
|
||||
- 📋 Логи и диагностика, включая журнал сбоев и действий бота
|
||||
- 📦 Проверка обновлений проекта и уведомления о новых релизах
|
||||
🛟 **Поддержка и модерация**
|
||||
- 🎫 Центр тикетов с приоритетами, статусами и перепиской.
|
||||
- 🧑⚖️ Разграничение ролей (админы, модераторы), журнал аудита действий.
|
||||
- ⏱️ SLA таймеры, автоматические напоминания и уведомления в канал.
|
||||
- 🚫 Массовые блокировки нарушителей и автоматическое снятие блокировок.
|
||||
- 🧾 История диалогов, прикреплённые файлы и быстрые ответы.
|
||||
|
||||
📨 **Коммуникации и поддержка**
|
||||
- 📢 Рассылки по сегментам и мгновенные оповещения
|
||||
- 🔔 Автоуведомления о продлении, задолженностях и автоплатежах
|
||||
- 💬 Система поддержки с HTML разметкой и история обращений
|
||||
- 📝 Настройка правил сервиса, FAQ и приветственных экранов
|
||||
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
|
||||
🔔 **Уведомления и коммуникации**
|
||||
- 📢 Топики для событий (покупки, триалы, техработы, ошибки интеграций).
|
||||
- 🔔 Настройка пользовательских и админских уведомлений, расписаний и каналов.
|
||||
- 📨 Шаблоны правил, оферты, FAQ, приветствий и страницы поддержки.
|
||||
- 💬 Автоматические сообщения о задолженностях, автоплатежах и продлениях.
|
||||
|
||||
📨 **Уведомления в закрытый канал**
|
||||
- 🎯 Активация триала, продления и конверсии в платную подписку
|
||||
- 💎 Покупка подписки и пополнение баланса
|
||||
- 🔄 Изменения параметров подписки пользователем
|
||||
- 🚧 Потеря соединения с Remnawave и включение техработ
|
||||
- 🗄️ Авто- и ручные бекапы/восстановления с отправкой файлов
|
||||
- ♻️ Выхлоп новых версий бота и результаты автоплатежей
|
||||
🧰 **Обслуживание и DevOps**
|
||||
- 🛠️ `install_bot.sh`: управление контейнерами, логами, обновлениями, прокси и резервными копиями.
|
||||
- 🚧 Ручной и авто-режим техработ с кастомными сообщениями.
|
||||
- 🗒️ Просмотр системных логов, health-check, мониторинг Remnawave и серверов.
|
||||
- ♻️ Проверка обновлений репозитория, уведомления о новых версиях и плавный деплой.
|
||||
|
||||
🗄️ **Бекапы и восстановление**
|
||||
- Ручной запуск бекапа с выбором содержимого (включая логи)
|
||||
- Автоматические бекапы по расписанию и управление retention-политикой
|
||||
- Восстановление из бекапа без остановки сервиса
|
||||
- Отправка копии бекапа в выделенный чат/топик и ведение метаданных
|
||||
- 🗓️ Планировщик авто-бекапов, гибкая политика хранения и компрессия.
|
||||
- 📦 Ручные бекапы с выбором содержимого (БД, конфиги, логи).
|
||||
- 📤 Отправка архивов в выделенный чат/топик, журнал попыток и статусов.
|
||||
- 🔁 Восстановление из бекапа без остановки бота, проверка целостности.
|
||||
|
||||
💳 **Автоплатежи и биллинг**
|
||||
- Настройки автоплатежа на уровне пользователя (вкл/выкл, дни до списания)
|
||||
- Принудительная проверка готовности автосписаний
|
||||
- Уведомления об успешных и неудачных автоплатежах с подсказками действий
|
||||
- Настройка минимального баланса и логирование всех списаний
|
||||
💳 **Биллинг и автоплатежи**
|
||||
- ⚙️ Глобальные настройки автосписаний, минимального баланса и напоминаний.
|
||||
- 🧪 Тестовые платежи для каждого провайдера прямо из админки.
|
||||
- 🪝 Управление вебхуками Tribute, YooKassa, CryptoBot, MulenPay и PayPalych.
|
||||
- 🧾 Экспорт транзакций, сверка с платёжками и интеграция с бухгалтерией.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1064,6 +1071,16 @@ WEBHOOK_PATH=/webhook
|
||||
- 📦 **Интеграция с Remnawave API** — автоматическое создание пользователей, управление сквадами и синхронизация подписок.
|
||||
- 🧾 **История операций** — хранение транзакций, уведомлений, рассылок, кампаний и бекапов для аудита.
|
||||
|
||||
### 🌐 Веб-API, мини-приложение и интеграции
|
||||
|
||||
- ⚙️ **FastAPI Web API** (`app/webapi`) с эндпоинтами для управления токенами, конфигурациями, рассылками, кампаниями, промогруппами, статистикой, подписками, транзакциями, тикетами и бекапами.
|
||||
- 🔑 **Управление API-ключами** — выпуск, отзыв, реактивация и аудит токенов прямо из админки.
|
||||
- 🛡️ **Гибкая авторизация** — API-key, Bearer, Basic Auth и cookie eGames для совместимости с внешними панелями.
|
||||
- 🛰️ **Mini App** — статический фронтенд в `miniapp/` и proxied эндпоинты `/miniapp/*` для отображения статуса подписки внутри Telegram.
|
||||
- 🧭 **App Config** — централизованная раздача ссылок на клиенты и инструкции (`app-config.json`, `miniapp/app-config.json`).
|
||||
- 🪝 **Платёжные вебхуки** — встроенные Flask-серверы для Tribute, YooKassa, CryptoBot, MulenPay и PayPalych (Pal24) с health-check и логированием.
|
||||
- 📡 **Мониторинг серверов** — REST-эндпоинты для просмотра нод Remnawave, статистики нагрузки и управления сквадами из внешних систем.
|
||||
|
||||
### 🧭 Навигация по проекту
|
||||
|
||||
- `app/bot.py` — точка входа бота и регистрация middlewares/handlers.
|
||||
@@ -1349,121 +1366,6 @@ ADMIN_NOTIFICATIONS_TOPIC_ID=123 # ID топика (опционал
|
||||
- **🔗 SQLAlchemy ORM** - безопасная работа с БД
|
||||
- **🚀 aiogram 3** - современная Telegram Bot API
|
||||
|
||||
### 📁 Структура проекта
|
||||
|
||||
```
|
||||
bedolaga_bot/
|
||||
├── 🎯 main.py # Точка входа
|
||||
├── 📦 requirements.txt # Зависимости
|
||||
├── ⚙️ .env.example # Конфиг
|
||||
├── ⚙️ app-config.json # Информация для гайда подключения
|
||||
│
|
||||
├── 📱 app/
|
||||
│ ├── 🤖 bot.py # Инициализация бота
|
||||
│ ├── ⚙️ config.py # Настройки
|
||||
│ ├── 🎛️ states.py # FSM состояния
|
||||
│ │
|
||||
│ ├── 🎮 handlers/ # Обработчики событий
|
||||
│ │ ├── 🏠 start.py # Регистрация и старт
|
||||
│ │ ├── 🛒 subscription.py # Подписки
|
||||
│ │ ├── 💰 balance.py # Баланс и платежи
|
||||
│ │ ├── 🎁 promocode.py # Промокоды
|
||||
│ │ ├── 👥 referral.py # Реферальная система
|
||||
│ │ ├── 🌟 stars_payment.py # Start платежи
|
||||
│ │ ├── common.py
|
||||
│ │ ├── 💬 support.py # Техподдержка
|
||||
│ │ └── 👑 admin/ # Админ панель
|
||||
│ │ ├── 📊 statistics.py # Статистика
|
||||
│ │ ├── 🗄️ backup.py # Бекапы
|
||||
│ │ ├── 👥 users.py # Управление юзерами
|
||||
│ │ ├── 🎫 promocodes.py # Управление промокодами
|
||||
│ │ ├── 🚧 maintenance.py # Тех работы
|
||||
│ │ ├── 📨 messages.py # Рассылки
|
||||
│ │ ├── 📨 user_messages.py # Рандомные сообщения в меню
|
||||
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
|
||||
│ │ ├── ⚙️ main.py # Админское меню
|
||||
│ │ ├── 📖 rules.py # Правила
|
||||
│ │ ├── 🙋 referrals.py # Правила
|
||||
│ │ ├── 🌎 servers.py # Сервера
|
||||
│ │ ├── 📱 subscriptions.py # Подписки
|
||||
│ │ ├── 🔍 monitoring.py # Мониторинг
|
||||
│ │ └── 🔗 remnawave.py # Система Remnawave
|
||||
│ │
|
||||
│ ├── 🗄️ database/ # База данных
|
||||
│ │ ├── 📊 models.py # Модели SQLAlchemy
|
||||
│ │ ├── 🔗 database.py # Подключение к БД
|
||||
│ │ ├── 🔄 universal_migration.py # Миграции
|
||||
│ │ └── 📝 crud/ # CRUD операции
|
||||
│ │ ├── 👤 user.py # Операции с пользователями
|
||||
│ │ ├── 📋 subscription.py # Операции с подписками
|
||||
│ │ ├── 💰 transaction.py # Операции с транзакциями
|
||||
│ │ ├── 📜 rules.py # Правила сервиса
|
||||
│ │ ├── 📜 subscription_conversion.py # Правила сервиса
|
||||
│ │ ├── 💳 yookassa.py # YooKassa операции
|
||||
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
|
||||
│ │ ├── 💳 cryptobot.py # CryptoBot операции
|
||||
│ │ ├── 🌐 server_squad.py # Серверы и сквады
|
||||
│ │ ├── 🎁 promocode.py # Промокоды
|
||||
│ │ └── 👥 referral.py # Партнерка
|
||||
│ │
|
||||
│ ├── 🔧 services/ # Бизнес-логика
|
||||
│ │ ├── 👤 user_service.py # Сервис пользователей
|
||||
│ │ ├── 📋 subscription_service.py # Сервис подписок
|
||||
│ │ ├── 💰 payment_service.py # Платежи
|
||||
│ │ ├── 🗄️ backup_service.py # Бекапы
|
||||
│ │ ├── 🎁 promocode_service.py # Промокоды
|
||||
│ │ ├── 🚧 maintenance_service.py # Промокоды
|
||||
│ │ ├── 👥 referral_service.py # Партнерка
|
||||
│ │ ├── 💬 admin_notification_service.py # Уведомления для администраторов в чаты
|
||||
│ │ ├── 🔍 monitoring_service.py # Мониторинг
|
||||
│ │ ├── ♻️ version_service.py # Проверка версий бота
|
||||
│ │ ├── 🎖️ tribute_service.py # Tribute платежи
|
||||
│ │ ├── 💳 yookassa_service.py # YooKassa платежи
|
||||
│ │ └── 🌐 remnawave_service.py # Интеграция с Remnawave
|
||||
│ │
|
||||
│ ├── 🛠️ utils/ # Утилиты
|
||||
│ │ ├── 🎨 decorators.py # Декораторы
|
||||
│ │ ├── 📝 formatters.py # Форматирование данных
|
||||
│ │ ├── ✅ validators.py # Валидация
|
||||
│ │ ├── ✅ subscription_utils.py # Проверка подписок
|
||||
│ │ ├── 📄 pagination.py # Пагинация
|
||||
│ │ ├── 📄 pricing_utils.py # Цены
|
||||
│ │ ├── 📄 message_patch.py
|
||||
│ │ ├── 📄 photo_message.py
|
||||
│ │ ├── ‼️ global_error.py # Обработка ошибок
|
||||
│ │ ├── 👤 user_utils.py # Утилиты для пользователей
|
||||
│ │ ├── 🫰 currency_converter.py # Курсы для CryptoBota
|
||||
│ │ └── ⚡ cache.py # Кеширование
|
||||
│ │
|
||||
│ ├── 🛡️ middlewares/ # Middleware
|
||||
│ │ ├── 🔐 auth.py # Авторизация
|
||||
│ │ ├── 📋 logging.py # Логирование
|
||||
│ │ ├── 🚧 maintenance.py # тех работы
|
||||
│ │ ├── 🔐 subscription_checker.py # тех работы
|
||||
│ │ ├── 🔐 channel_checker.py
|
||||
│ │ └── ⏱️ throttling.py # Ограничение запросов
|
||||
│ │
|
||||
│ ├── 🌐 localization/ # Локализация
|
||||
│ │ ├── 📝 texts.py # Тексты интерфейса
|
||||
│ │ └── 🗣️ languages/
|
||||
│ │
|
||||
│ ├── ⌨️ keyboards/ # Клавиатуры
|
||||
│ │ ├── 🔗 inline.py # Inline клавиатуры
|
||||
│ │ ├── 💬 reply.py # Reply клавиатуры
|
||||
│ │ └── 👑 admin.py # Админские клавиатуры
|
||||
│ │
|
||||
│ └── 🔌 external/ # Внешние API
|
||||
│ ├── 🌐 remnawave_api.py # Remnawave API
|
||||
│ ├── ⭐ telegram_stars.py # Telegram Stars
|
||||
│ ├── 💳 yookassa_webhook.py # YooKassa webhook
|
||||
│ ├── 🌐 webhook_server.py # Webhook сервер
|
||||
│ ├── 💳 cryptobot.py # CryptoBot Api
|
||||
│ └── 🎖️ tribute.py # Tribute платежи
|
||||
│
|
||||
├── 🔄 migrations/ # Миграции БД
|
||||
└── 📋 logs/ # Логи системы
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Устранение неполадок
|
||||
@@ -1707,62 +1609,146 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
|
||||
<tr>
|
||||
<td>🥇</td>
|
||||
<td><strong>@SmartSystemCompany</strong></td>
|
||||
<td>₽8,500</td>
|
||||
<td>За щедрую поддержку и вклад в развитие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥈</td>
|
||||
<td><strong>@pilot_737800</strong></td>
|
||||
<td>₽4,750</td>
|
||||
<td>₽7,750</td>
|
||||
<td>За веру в проект с самого начала</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥈</td>
|
||||
<td><strong>@Legacyyy777</strong></td>
|
||||
<td>₽2,600</td>
|
||||
<td>За ценные предложения по улучшению</td>
|
||||
<td><strong>@psych0O</strong></td>
|
||||
<td>$60</td>
|
||||
<td>За щедрую поддержку и вклад в развитие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥉</td>
|
||||
<td><strong>Илья (@ispanec_nn)</strong></td>
|
||||
<td>$30</td>
|
||||
<td>За активное тестирование и фидбек</td>
|
||||
<td><strong>@Vldmrmtn</strong></td>
|
||||
<td>₽5,000</td>
|
||||
<td>За значительную поддержку проекта</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td><strong>@k0tbtc</strong></td>
|
||||
<td>₽3,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td><strong>@Legacyyy777</strong></td>
|
||||
<td>₽2,900</td>
|
||||
<td>За ценные предложения по улучшению</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td><strong>@sklvg</strong></td>
|
||||
<td>₽3,000</td>
|
||||
<td>За международную поддержку</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>7</td>
|
||||
<td><strong>@bolgov0zero</strong></td>
|
||||
<td>₽2,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>8</td>
|
||||
<td><strong>@i_burjuy</strong></td>
|
||||
<td>₽1,300</td>
|
||||
<td>За активное участие в проекте</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>9</td>
|
||||
<td><strong>@kr33njee</strong></td>
|
||||
<td>₽1,550</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>10</td>
|
||||
<td><strong>@Nav1_0</strong></td>
|
||||
<td>₽2,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>11</td>
|
||||
<td><strong>@vitos_7771997</strong></td>
|
||||
<td>₽1,500</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>12</td>
|
||||
<td><strong>@haxonate</strong></td>
|
||||
<td>₽1,500</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>13</td>
|
||||
<td><strong>@fso404</strong></td>
|
||||
<td>₽1,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td>14</td>
|
||||
<td><strong>@edward_forix</strong></td>
|
||||
<td>₽1,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td><strong>@Nav1_0</strong></td>
|
||||
<td>₽1,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
<td>15</td>
|
||||
<td><strong>@Илья (@ispanec_nn)</strong></td>
|
||||
<td>$30</td>
|
||||
<td>За активное тестирование и фидбек</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td><strong>@kr33njee</strong></td>
|
||||
<td>₽800</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
<td>16</td>
|
||||
<td><strong>@DonMatteoRU</strong></td>
|
||||
<td>₽500</td>
|
||||
<td>За участие в развитии</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>7</td>
|
||||
<td><strong>@SmartSystemCompany</strong></td>
|
||||
<td>₽600</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
<td>17</td>
|
||||
<td><strong>@pedzeo</strong></td>
|
||||
<td>₽500</td>
|
||||
<td>За участие в развитии</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>8</td>
|
||||
<td>18</td>
|
||||
<td><strong>@suzumuj</strong></td>
|
||||
<td>₽400</td>
|
||||
<td>За участие в развитии</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>19</td>
|
||||
<td><strong>@andrey_93rus</strong></td>
|
||||
<td>₽300</td>
|
||||
<td>За участие в развитии</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>20</td>
|
||||
<td><strong>@PhiLin58</strong></td>
|
||||
<td>₽300</td>
|
||||
<td>За участие в развитии</td>
|
||||
|
||||
+6
-1
@@ -10,7 +10,8 @@ from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.throttling import ThrottlingMiddleware
|
||||
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
@@ -102,6 +103,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
display_name_middleware = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_middleware)
|
||||
dp.callback_query.middleware(display_name_middleware)
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
|
||||
+128
-7
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -10,9 +12,16 @@ from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
|
||||
"tme",
|
||||
"joingroup",
|
||||
]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
|
||||
BOT_TOKEN: str
|
||||
BOT_USERNAME: Optional[str] = None
|
||||
ADMIN_IDS: str = ""
|
||||
SUPPORT_USERNAME: str = "@support"
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
@@ -130,8 +139,9 @@ class Settings(BaseSettings):
|
||||
REFERRED_USER_REWARD: int = 0
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
|
||||
DEFAULT_AUTOPAY_ENABLED: bool = False
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
|
||||
|
||||
MONITORING_INTERVAL: int = 60
|
||||
@@ -194,6 +204,10 @@ class Settings(BaseSettings):
|
||||
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
|
||||
MULENPAY_LANGUAGE: str = "ru"
|
||||
MULENPAY_VAT_CODE: int = 0
|
||||
|
||||
DISPLAY_NAME_BANNED_KEYWORDS: str = "\n".join(
|
||||
DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS
|
||||
)
|
||||
MULENPAY_PAYMENT_SUBJECT: int = 4
|
||||
MULENPAY_PAYMENT_MODE: int = 4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
@@ -276,6 +290,9 @@ class Settings(BaseSettings):
|
||||
BACKUP_SEND_CHAT_ID: Optional[str] = None
|
||||
BACKUP_SEND_TOPIC_ID: Optional[int] = None
|
||||
|
||||
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
|
||||
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
|
||||
|
||||
@field_validator('SERVER_STATUS_MODE', mode='before')
|
||||
@classmethod
|
||||
def normalize_server_status_mode(cls, value: Optional[str]) -> str:
|
||||
@@ -448,6 +465,29 @@ class Settings(BaseSettings):
|
||||
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
return description
|
||||
|
||||
def get_display_name_banned_keywords(self) -> List[str]:
|
||||
raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS
|
||||
if raw_value is None:
|
||||
return []
|
||||
|
||||
if isinstance(raw_value, str):
|
||||
candidates = re.split(r"[\n,]+", raw_value)
|
||||
else:
|
||||
candidates = list(raw_value)
|
||||
|
||||
unique: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
normalized = str(candidate).strip().lower()
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
unique.append(normalized)
|
||||
|
||||
return unique
|
||||
|
||||
def get_autopay_warning_days(self) -> List[int]:
|
||||
try:
|
||||
@@ -459,6 +499,15 @@ class Settings(BaseSettings):
|
||||
return [3, 1]
|
||||
except (ValueError, AttributeError):
|
||||
return [3, 1]
|
||||
|
||||
def is_autopay_enabled_by_default(self) -> bool:
|
||||
value = getattr(self, "DEFAULT_AUTOPAY_ENABLED", True)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
try:
|
||||
@@ -475,8 +524,14 @@ class Settings(BaseSettings):
|
||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks // 100
|
||||
return f"{rubles} ₽"
|
||||
sign = "-" if price_kopeks < 0 else ""
|
||||
rubles, kopeks = divmod(abs(price_kopeks), 100)
|
||||
|
||||
if kopeks:
|
||||
value = f"{sign}{rubles}.{kopeks:02d}".rstrip("0").rstrip(".")
|
||||
return f"{value} ₽"
|
||||
|
||||
return f"{sign}{rubles} ₽"
|
||||
|
||||
def get_reports_chat_id(self) -> Optional[str]:
|
||||
if self.ADMIN_REPORTS_CHAT_ID:
|
||||
@@ -512,7 +567,14 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_trial_warning_hours(self) -> int:
|
||||
return self.TRIAL_WARNING_HOURS
|
||||
|
||||
|
||||
def get_bot_username(self) -> Optional[str]:
|
||||
username = getattr(self, "BOT_USERNAME", None)
|
||||
if not username:
|
||||
return None
|
||||
normalized = str(username).strip().lstrip("@")
|
||||
return normalized or None
|
||||
|
||||
def is_notifications_enabled(self) -> bool:
|
||||
return self.ENABLE_NOTIFICATIONS
|
||||
|
||||
@@ -556,6 +618,37 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_app_config_cache_ttl(self) -> int:
|
||||
return self.APP_CONFIG_CACHE_TTL
|
||||
|
||||
def build_external_admin_token(self, bot_username: str) -> str:
|
||||
"""Генерирует детерминированный и криптографически стойкий токен внешней админки."""
|
||||
normalized = (bot_username or "").strip().lstrip("@").lower()
|
||||
if not normalized:
|
||||
raise ValueError("Bot username is required to build external admin token")
|
||||
|
||||
secret = (self.BOT_TOKEN or "").strip()
|
||||
if not secret:
|
||||
raise ValueError("Bot token is required to build external admin token")
|
||||
|
||||
digest = hmac.new(
|
||||
key=secret.encode("utf-8"),
|
||||
msg=f"remnawave.external_admin::{normalized}".encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
return digest[:48]
|
||||
|
||||
def get_external_admin_token(self) -> Optional[str]:
|
||||
token = (self.EXTERNAL_ADMIN_TOKEN or "").strip()
|
||||
return token or None
|
||||
|
||||
def get_external_admin_bot_id(self) -> Optional[int]:
|
||||
try:
|
||||
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
|
||||
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
|
||||
logging.getLogger(__name__).warning(
|
||||
"Некорректный идентификатор бота для внешней админки: %s",
|
||||
self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
|
||||
)
|
||||
return None
|
||||
|
||||
def is_traffic_selectable(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
|
||||
@@ -791,14 +884,42 @@ class Settings(BaseSettings):
|
||||
return (self.BACKUP_SEND_ENABLED and
|
||||
self.get_backup_send_chat_id() is not None)
|
||||
|
||||
def get_referred_user_reward_kopeks(self) -> int:
|
||||
"""Return the referred user reward normalized to kopeks.
|
||||
|
||||
Historically the value was stored in kopeks, however some
|
||||
installations provide it in rubles. To keep backward compatibility we
|
||||
treat any value greater than or equal to one thousand as already being
|
||||
in kopeks (≥ 10 ₽). Smaller positive values are assumed to be provided
|
||||
in rubles and therefore converted to kopeks.
|
||||
"""
|
||||
|
||||
raw_value = getattr(self, "REFERRED_USER_REWARD", 0)
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
if value <= 0:
|
||||
return 0
|
||||
|
||||
if value >= 1000:
|
||||
return value
|
||||
|
||||
return value * 100
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
|
||||
|
||||
return {
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
|
||||
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
|
||||
"referred_user_reward": self.REFERRED_USER_REWARD
|
||||
"referred_user_reward": referred_reward_kopeks,
|
||||
"referred_user_reward_kopeks": referred_reward_kopeks,
|
||||
}
|
||||
|
||||
def is_referral_notifications_enabled(self) -> bool:
|
||||
|
||||
@@ -110,6 +110,31 @@ async def list_discount_offers(
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def list_active_discount_offers_for_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> List[DiscountOffer]:
|
||||
"""Return active (not yet claimed) offers for a user."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.expires_at > now,
|
||||
)
|
||||
.order_by(DiscountOffer.expires_at.asc())
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_discount_offers(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
MainMenuButton,
|
||||
MainMenuButtonActionType,
|
||||
MainMenuButtonVisibility,
|
||||
)
|
||||
|
||||
|
||||
async def count_main_menu_buttons(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count()).select_from(MainMenuButton))
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def get_main_menu_buttons(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> list[MainMenuButton]:
|
||||
stmt = select(MainMenuButton).order_by(
|
||||
MainMenuButton.display_order.asc(),
|
||||
MainMenuButton.id.asc(),
|
||||
)
|
||||
|
||||
if offset is not None:
|
||||
stmt = stmt.offset(offset)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_main_menu_button_by_id(
|
||||
db: AsyncSession, button_id: int
|
||||
) -> MainMenuButton | None:
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).where(MainMenuButton.id == button_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_next_display_order(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.max(MainMenuButton.display_order)))
|
||||
current_max = result.scalar()
|
||||
return (int(current_max) if current_max is not None else 0) + 1
|
||||
|
||||
|
||||
def _enum_value(value, enum_cls):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, enum_cls):
|
||||
return value.value
|
||||
return str(value)
|
||||
|
||||
|
||||
async def create_main_menu_button(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
text: str,
|
||||
action_type: MainMenuButtonActionType | str,
|
||||
action_value: str,
|
||||
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
|
||||
is_active: bool = True,
|
||||
display_order: Optional[int] = None,
|
||||
) -> MainMenuButton:
|
||||
if display_order is None:
|
||||
display_order = await get_next_display_order(db)
|
||||
|
||||
button = MainMenuButton(
|
||||
text=text,
|
||||
action_type=_enum_value(action_type, MainMenuButtonActionType),
|
||||
action_value=action_value,
|
||||
visibility=_enum_value(visibility, MainMenuButtonVisibility)
|
||||
or MainMenuButtonVisibility.ALL.value,
|
||||
is_active=bool(is_active),
|
||||
display_order=int(display_order),
|
||||
)
|
||||
|
||||
db.add(button)
|
||||
await db.commit()
|
||||
await db.refresh(button)
|
||||
return button
|
||||
|
||||
|
||||
async def update_main_menu_button(
|
||||
db: AsyncSession,
|
||||
button: MainMenuButton,
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
action_type: MainMenuButtonActionType | str | None = None,
|
||||
action_value: Optional[str] = None,
|
||||
visibility: MainMenuButtonVisibility | str | None = None,
|
||||
is_active: Optional[bool] = None,
|
||||
display_order: Optional[int] = None,
|
||||
) -> MainMenuButton:
|
||||
if text is not None:
|
||||
button.text = text
|
||||
if action_type is not None:
|
||||
button.action_type = _enum_value(action_type, MainMenuButtonActionType)
|
||||
if action_value is not None:
|
||||
button.action_value = action_value
|
||||
if visibility is not None:
|
||||
button.visibility = _enum_value(visibility, MainMenuButtonVisibility)
|
||||
if is_active is not None:
|
||||
button.is_active = bool(is_active)
|
||||
if display_order is not None:
|
||||
button.display_order = int(display_order)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(button)
|
||||
return button
|
||||
|
||||
|
||||
async def delete_main_menu_button(db: AsyncSession, button: MainMenuButton) -> None:
|
||||
await db.delete(button)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def reorder_main_menu_buttons(
|
||||
db: AsyncSession,
|
||||
ordered_ids: Sequence[int],
|
||||
) -> None:
|
||||
if not ordered_ids:
|
||||
return
|
||||
|
||||
order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)}
|
||||
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))
|
||||
)
|
||||
buttons = result.scalars().all()
|
||||
|
||||
for button in buttons:
|
||||
desired_order = order_map.get(button.id)
|
||||
if desired_order is not None:
|
||||
button.display_order = desired_order
|
||||
|
||||
await db.commit()
|
||||
@@ -50,6 +50,25 @@ async def get_promo_groups_with_counts(
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
.order_by(PromoGroup.auto_assign_total_spent_kopeks, PromoGroup.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def has_auto_assign_promo_groups(db: AsyncSession) -> bool:
|
||||
result = await db.execute(
|
||||
select(func.count(PromoGroup.id))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
)
|
||||
return bool(result.scalar_one())
|
||||
|
||||
|
||||
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
|
||||
return await db.get(PromoGroup, group_id)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
@@ -16,7 +17,14 @@ from sqlalchemy import (
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
ServerSquad,
|
||||
SubscriptionServer,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,6 +46,7 @@ async def create_server_squad(
|
||||
description: str = None,
|
||||
max_users: int = None,
|
||||
is_available: bool = True,
|
||||
is_trial_eligible: bool = False,
|
||||
promo_group_ids: Optional[Iterable[int]] = None,
|
||||
) -> ServerSquad:
|
||||
|
||||
@@ -70,6 +79,7 @@ async def create_server_squad(
|
||||
description=description,
|
||||
max_users=max_users,
|
||||
is_available=is_available,
|
||||
is_trial_eligible=is_trial_eligible,
|
||||
allowed_promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
@@ -199,7 +209,7 @@ async def update_server_squad(
|
||||
|
||||
valid_fields = {
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order'
|
||||
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
|
||||
}
|
||||
|
||||
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
|
||||
@@ -393,8 +403,70 @@ async def get_server_connected_users(
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_trial_eligible_server_squads(
|
||||
db: AsyncSession,
|
||||
include_unavailable: bool = False,
|
||||
) -> List[ServerSquad]:
|
||||
|
||||
query = select(ServerSquad).where(ServerSquad.is_trial_eligible.is_(True))
|
||||
|
||||
result = await db.execute(query)
|
||||
squads = result.scalars().unique().all()
|
||||
|
||||
if include_unavailable:
|
||||
return squads
|
||||
|
||||
preferred_squads: List[ServerSquad] = []
|
||||
fallback_squads: List[ServerSquad] = []
|
||||
|
||||
for squad in squads:
|
||||
current_users = squad.current_users or 0
|
||||
is_full = squad.max_users is not None and current_users >= squad.max_users
|
||||
|
||||
if is_full:
|
||||
continue
|
||||
|
||||
if squad.is_available:
|
||||
preferred_squads.append(squad)
|
||||
else:
|
||||
fallback_squads.append(squad)
|
||||
|
||||
if preferred_squads:
|
||||
return preferred_squads
|
||||
|
||||
if fallback_squads:
|
||||
return fallback_squads
|
||||
|
||||
return squads
|
||||
|
||||
|
||||
async def choose_random_trial_server_squad(
|
||||
db: AsyncSession,
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
squads = await get_trial_eligible_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return None
|
||||
|
||||
return random.choice(squads)
|
||||
|
||||
|
||||
async def get_random_trial_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
|
||||
squad = await choose_random_trial_server_squad(db)
|
||||
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
|
||||
country_names = {
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
@@ -430,10 +502,10 @@ def _extract_country_code(original_name: str) -> Optional[str]:
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
|
||||
available_result = await db.execute(
|
||||
select(func.count(ServerSquad.id))
|
||||
.where(ServerSquad.is_available == True)
|
||||
@@ -472,6 +544,25 @@ async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
'total_revenue_rubles': total_revenue_kopeks / 100
|
||||
}
|
||||
|
||||
|
||||
async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int:
|
||||
"""Возвращает количество активных подписок, подключенных к указанному скваду."""
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
]
|
||||
),
|
||||
cast(Subscription.connected_squads, String).like(f'%"{squad_uuid}"%'),
|
||||
)
|
||||
)
|
||||
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def add_user_to_servers(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from typing import Iterable, Optional, List, Tuple
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -47,10 +47,31 @@ async def create_trial_subscription(
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
|
||||
|
||||
if not squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
squad_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
|
||||
if squad_uuid:
|
||||
logger.debug(
|
||||
"Выбран сквад %s для триальной подписки пользователя %s",
|
||||
squad_uuid,
|
||||
user_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось получить сквад для триальной подписки пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
squad_uuid = settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -59,14 +80,43 @@ async def create_trial_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=[squad_uuid] if squad_uuid else []
|
||||
connected_squads=[squad_uuid] if squad_uuid else [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
|
||||
|
||||
if squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
add_user_to_servers,
|
||||
)
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, [squad_uuid])
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
"📈 Обновлен счетчик пользователей для триального сквада %s",
|
||||
squad_uuid,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)",
|
||||
squad_uuid,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s",
|
||||
squad_uuid,
|
||||
error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -74,9 +124,10 @@ async def create_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
@@ -89,7 +140,9 @@ async def create_paid_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or []
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
@@ -97,6 +150,36 @@ async def create_paid_subscription(
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
|
||||
|
||||
squad_uuids = list(connected_squads or [])
|
||||
if update_server_counters and squad_uuids:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
add_user_to_servers,
|
||||
)
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, squad_uuids)
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
"📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)",
|
||||
user_id,
|
||||
squad_uuids,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)",
|
||||
user_id,
|
||||
squad_uuids,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -110,7 +193,14 @@ async def extend_subscription(
|
||||
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
|
||||
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
if days < 0:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(
|
||||
"📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s",
|
||||
abs(days),
|
||||
subscription.end_date,
|
||||
)
|
||||
elif subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
|
||||
else:
|
||||
@@ -133,7 +223,7 @@ async def extend_subscription(
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value and days > 0:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
|
||||
@@ -223,6 +313,64 @@ async def remove_subscription_squad(
|
||||
return subscription
|
||||
|
||||
|
||||
async def decrement_subscription_server_counts(
|
||||
db: AsyncSession,
|
||||
subscription: Optional[Subscription],
|
||||
*,
|
||||
subscription_servers: Optional[Iterable[SubscriptionServer]] = None,
|
||||
) -> None:
|
||||
"""Decrease server counters linked to the provided subscription."""
|
||||
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
server_ids: set[int] = set()
|
||||
|
||||
if subscription_servers is not None:
|
||||
for sub_server in subscription_servers:
|
||||
if sub_server and sub_server.server_squad_id is not None:
|
||||
server_ids.add(sub_server.server_squad_id)
|
||||
else:
|
||||
try:
|
||||
ids_from_links = await get_subscription_server_ids(db, subscription.id)
|
||||
server_ids.update(ids_from_links)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
connected_squads = list(subscription.connected_squads or [])
|
||||
if connected_squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
|
||||
squad_server_ids = await get_server_ids_by_uuids(db, connected_squads)
|
||||
server_ids.update(squad_server_ids)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
if not server_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import remove_user_from_servers
|
||||
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s",
|
||||
list(server_ids),
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
async def update_subscription_autopay(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
@@ -986,7 +1134,9 @@ async def create_subscription(
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = ""
|
||||
subscription_crypto_link: str = "",
|
||||
autopay_enabled: Optional[bool] = None,
|
||||
autopay_days_before: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
|
||||
if end_date is None:
|
||||
@@ -1006,7 +1156,17 @@ async def create_subscription(
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
autopay_enabled=(
|
||||
settings.is_autopay_enabled_by_default()
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
),
|
||||
autopay_days_before=(
|
||||
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
),
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
|
||||
@@ -39,6 +39,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
@@ -56,6 +57,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
+51
-1
@@ -79,6 +79,17 @@ class PaymentMethod(Enum):
|
||||
PAL24 = "pal24"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class MainMenuButtonActionType(Enum):
|
||||
URL = "url"
|
||||
MINI_APP = "mini_app"
|
||||
|
||||
|
||||
class MainMenuButtonVisibility(Enum):
|
||||
ALL = "all"
|
||||
ADMINS = "admins"
|
||||
SUBSCRIBERS = "subscribers"
|
||||
|
||||
class YooKassaPayment(Base):
|
||||
__tablename__ = "yookassa_payments"
|
||||
|
||||
@@ -963,6 +974,7 @@ class ServerSquad(Base):
|
||||
country_code = Column(String(5), nullable=True)
|
||||
|
||||
is_available = Column(Boolean, default=True)
|
||||
is_trial_eligible = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
price_kopeks = Column(Integer, default=0)
|
||||
|
||||
@@ -1252,4 +1264,42 @@ class WebApiToken(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "revoked"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
|
||||
|
||||
class MainMenuButton(Base):
|
||||
__tablename__ = "main_menu_buttons"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
text = Column(String(64), nullable=False)
|
||||
action_type = Column(String(20), nullable=False)
|
||||
action_value = Column(Text, nullable=False)
|
||||
visibility = Column(String(20), nullable=False, default=MainMenuButtonVisibility.ALL.value)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_main_menu_buttons_order", "display_order", "id"),
|
||||
)
|
||||
|
||||
@property
|
||||
def action_type_enum(self) -> MainMenuButtonActionType:
|
||||
try:
|
||||
return MainMenuButtonActionType(self.action_type)
|
||||
except ValueError:
|
||||
return MainMenuButtonActionType.URL
|
||||
|
||||
@property
|
||||
def visibility_enum(self) -> MainMenuButtonVisibility:
|
||||
try:
|
||||
return MainMenuButtonVisibility(self.visibility)
|
||||
except ValueError:
|
||||
return MainMenuButtonVisibility.ALL
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<MainMenuButton id={self.id} text='{self.text}' "
|
||||
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
|
||||
)
|
||||
|
||||
@@ -1062,6 +1062,78 @@ async def create_promo_offer_templates_table():
|
||||
return False
|
||||
|
||||
|
||||
async def create_main_menu_buttons_table() -> bool:
|
||||
table_exists = await check_table_exists('main_menu_buttons')
|
||||
if table_exists:
|
||||
logger.info("Таблица main_menu_buttons уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE main_menu_buttons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text VARCHAR(64) NOT NULL,
|
||||
action_type VARCHAR(20) NOT NULL,
|
||||
action_value TEXT NOT NULL,
|
||||
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
|
||||
"""
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS main_menu_buttons (
|
||||
id SERIAL PRIMARY KEY,
|
||||
text VARCHAR(64) NOT NULL,
|
||||
action_type VARCHAR(20) NOT NULL,
|
||||
action_value TEXT NOT NULL,
|
||||
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
|
||||
"""
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS main_menu_buttons (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
text VARCHAR(64) NOT NULL,
|
||||
action_type VARCHAR(20) NOT NULL,
|
||||
action_value TEXT NOT NULL,
|
||||
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
display_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для таблицы main_menu_buttons: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
|
||||
logger.info("✅ Таблица main_menu_buttons успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы main_menu_buttons: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_promo_offer_logs_table() -> bool:
|
||||
table_exists = await check_table_exists('promo_offer_logs')
|
||||
if table_exists:
|
||||
@@ -2367,6 +2439,41 @@ async def ensure_server_promo_groups_setup() -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def add_server_trial_flag_column() -> bool:
|
||||
column_exists = await check_column_exists('server_squads', 'is_trial_eligible')
|
||||
if column_exists:
|
||||
logger.info("Колонка is_trial_eligible уже существует в server_squads")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'BOOLEAN NOT NULL DEFAULT 0'
|
||||
elif db_type == 'postgresql':
|
||||
column_def = 'BOOLEAN NOT NULL DEFAULT FALSE'
|
||||
else:
|
||||
column_def = 'BOOLEAN NOT NULL DEFAULT FALSE'
|
||||
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE server_squads ADD COLUMN is_trial_eligible {column_def}")
|
||||
)
|
||||
|
||||
if db_type == 'postgresql':
|
||||
await conn.execute(
|
||||
text("ALTER TABLE server_squads ALTER COLUMN is_trial_eligible SET DEFAULT FALSE")
|
||||
)
|
||||
|
||||
logger.info("✅ Добавлена колонка is_trial_eligible в server_squads")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления колонки is_trial_eligible: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_system_settings_table() -> bool:
|
||||
table_exists = await check_table_exists("system_settings")
|
||||
if table_exists:
|
||||
@@ -2802,6 +2909,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===")
|
||||
trial_column_ready = await add_server_trial_flag_column()
|
||||
if trial_column_ready:
|
||||
logger.info("✅ Колонка is_trial_eligible готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с колонкой is_trial_eligible")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===")
|
||||
privacy_policies_ready = await create_privacy_policies_table()
|
||||
if privacy_policies_ready:
|
||||
@@ -2902,6 +3016,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей promo_offer_templates")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===")
|
||||
main_menu_buttons_created = await create_main_menu_buttons_table()
|
||||
if main_menu_buttons_created:
|
||||
logger.info("✅ Таблица main_menu_buttons готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей main_menu_buttons")
|
||||
|
||||
template_columns_ready = await ensure_promo_offer_template_active_duration_column()
|
||||
if template_columns_ready:
|
||||
logger.info("✅ Колонка active_discount_hours промо-предложений готова")
|
||||
@@ -3116,6 +3237,7 @@ async def check_migration_status():
|
||||
"subscription_conversions_table": False,
|
||||
"promo_groups_table": False,
|
||||
"server_promo_groups_table": False,
|
||||
"server_squads_trial_column": False,
|
||||
"privacy_policies_table": False,
|
||||
"public_offers_table": False,
|
||||
"users_promo_group_column": False,
|
||||
@@ -3147,6 +3269,7 @@ async def check_migration_status():
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
||||
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
|
||||
status["server_squads_trial_column"] = await check_column_exists('server_squads', 'is_trial_eligible')
|
||||
|
||||
status["discount_offers_table"] = await check_table_exists('discount_offers')
|
||||
status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type')
|
||||
@@ -3201,6 +3324,7 @@ async def check_migration_status():
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок",
|
||||
"promo_groups_table": "Таблица промо-групп",
|
||||
"server_promo_groups_table": "Связи серверов и промогрупп",
|
||||
"server_squads_trial_column": "Колонка триального назначения у серверов",
|
||||
"users_promo_group_column": "Колонка promo_group_id у пользователей",
|
||||
"promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
|
||||
"promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп",
|
||||
|
||||
Vendored
+12
-10
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -18,8 +19,9 @@ class TelegramStarsService:
|
||||
return settings.rubles_to_stars(rubles)
|
||||
|
||||
@staticmethod
|
||||
def calculate_rubles_from_stars(stars: int) -> float:
|
||||
return settings.stars_to_rubles(stars)
|
||||
def calculate_rubles_from_stars(stars: int) -> Decimal:
|
||||
rate = Decimal(str(settings.get_stars_rate()))
|
||||
return (Decimal(stars) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
@@ -31,10 +33,10 @@ class TelegramStarsService:
|
||||
start_parameter: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
stars_amount = self.calculate_stars_from_rubles(float(amount_rubles))
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title=title,
|
||||
description=description,
|
||||
@@ -46,7 +48,7 @@ class TelegramStarsService:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽) "
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{settings.format_price(amount_kopeks)}) "
|
||||
f"для {chat_id}, курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return invoice_link
|
||||
@@ -65,8 +67,8 @@ class TelegramStarsService:
|
||||
keyboard: Optional[InlineKeyboardMarkup] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
stars_amount = self.calculate_stars_from_rubles(float(amount_rubles))
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
message = await self.bot.send_invoice(
|
||||
@@ -82,12 +84,12 @@ class TelegramStarsService:
|
||||
|
||||
logger.info(
|
||||
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
|
||||
f"(~{int(amount_rubles)}₽), курс: {stars_rate}₽/⭐"
|
||||
f"(~{settings.format_price(amount_kopeks)}), курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"stars_amount": stars_amount,
|
||||
"rubles_amount": amount_rubles,
|
||||
"rubles_amount": float(amount_rubles),
|
||||
"payload": payload
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ from app.config import settings
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.tribute_service import TributeService
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
from app.states import BotConfigStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.currency_converter import currency_converter
|
||||
@@ -107,6 +110,12 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"icon": "⚡",
|
||||
"categories": ("WEB_API", "WEBHOOK", "LOG", "DEBUG"),
|
||||
},
|
||||
"external_admin": {
|
||||
"title": "🛡️ Внешняя админка",
|
||||
"description": "Токен, по которому внешняя админка проверяет запросы.",
|
||||
"icon": "🛡️",
|
||||
"categories": ("EXTERNAL_ADMIN",),
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
|
||||
@@ -123,6 +132,7 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
|
||||
"server",
|
||||
"maintenance",
|
||||
"advanced",
|
||||
"external_admin",
|
||||
)
|
||||
|
||||
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
|
||||
@@ -269,7 +279,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
settings.REFERRAL_COMMISSION_PERCENT
|
||||
or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
|
||||
or settings.REFERRAL_INVITER_BONUS_KOPEKS
|
||||
or settings.REFERRED_USER_REWARD
|
||||
or settings.get_referred_user_reward_kopeks()
|
||||
)
|
||||
return ("🟢", "Программа активна") if active else ("⚪", "Бонусы не заданы")
|
||||
|
||||
@@ -687,6 +697,12 @@ async def apply_preset(
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, setting_key, value)
|
||||
applied.append(setting_key)
|
||||
except ReadOnlySettingError:
|
||||
logging.getLogger(__name__).info(
|
||||
"Пропускаем настройку %s из пресета %s: только для чтения",
|
||||
setting_key,
|
||||
preset_key,
|
||||
)
|
||||
except Exception as error:
|
||||
logging.getLogger(__name__).warning(
|
||||
"Не удалось применить пресет %s для %s: %s",
|
||||
@@ -854,8 +870,14 @@ async def handle_import_message(
|
||||
errors.append(f"{setting_key}: {error}")
|
||||
continue
|
||||
|
||||
await bot_configuration_service.set_value(db, setting_key, value_to_apply)
|
||||
applied.append(setting_key)
|
||||
if bot_configuration_service.is_read_only(setting_key):
|
||||
skipped.append(setting_key)
|
||||
continue
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, setting_key, value_to_apply)
|
||||
applied.append(setting_key)
|
||||
except ReadOnlySettingError:
|
||||
skipped.append(setting_key)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1361,9 +1383,10 @@ def _build_setting_keyboard(
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
rows: list[list[types.InlineKeyboardButton]] = []
|
||||
callback_token = bot_configuration_service.get_callback_token(key)
|
||||
is_read_only = bot_configuration_service.is_read_only(key)
|
||||
|
||||
choice_options = bot_configuration_service.get_choice_options(key)
|
||||
if choice_options:
|
||||
if choice_options and not is_read_only:
|
||||
current_value = bot_configuration_service.get_current_value(key)
|
||||
choice_buttons: list[types.InlineKeyboardButton] = []
|
||||
for option in choice_options:
|
||||
@@ -1385,7 +1408,7 @@ def _build_setting_keyboard(
|
||||
for chunk in _chunk(choice_buttons, 2):
|
||||
rows.append(list(chunk))
|
||||
|
||||
if definition.python_type is bool:
|
||||
if definition.python_type is bool and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🔁 Переключить",
|
||||
@@ -1395,16 +1418,17 @@ def _build_setting_keyboard(
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Изменить",
|
||||
callback_data=(
|
||||
f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}"
|
||||
),
|
||||
)
|
||||
])
|
||||
if not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Изменить",
|
||||
callback_data=(
|
||||
f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
if bot_configuration_service.has_override(key):
|
||||
if bot_configuration_service.has_override(key) and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="♻️ Сбросить",
|
||||
@@ -1414,6 +1438,14 @@ def _build_setting_keyboard(
|
||||
)
|
||||
])
|
||||
|
||||
if is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🔒 Только для чтения",
|
||||
callback_data="botcfg_group:noop",
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
@@ -1438,6 +1470,11 @@ def _render_setting_text(key: str) -> str:
|
||||
f"📌 <b>Текущее:</b> {summary['current']}",
|
||||
f"📦 <b>По умолчанию:</b> {summary['original']}",
|
||||
f"✳️ <b>Переопределено:</b> {'Да' if summary['has_override'] else 'Нет'}",
|
||||
*(
|
||||
["🔒 <b>Режим:</b> Только для чтения (управляется автоматически)"]
|
||||
if summary.get("is_read_only")
|
||||
else []
|
||||
),
|
||||
"",
|
||||
f"📘 <b>Описание:</b> {guidance['description']}",
|
||||
f"📐 <b>Формат:</b> {guidance['format']}",
|
||||
@@ -2066,6 +2103,9 @@ async def start_edit_setting(
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
|
||||
summary = bot_configuration_service.get_setting_summary(key)
|
||||
@@ -2131,13 +2171,23 @@ async def handle_edit_setting(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await message.answer("⚠️ Эта настройка доступна только для чтения.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
value = bot_configuration_service.parse_user_value(key, message.text or "")
|
||||
except ValueError as error:
|
||||
await message.answer(f"⚠️ {error}")
|
||||
return
|
||||
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError:
|
||||
await message.answer("⚠️ Эта настройка доступна только для чтения.")
|
||||
await state.clear()
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
@@ -2172,13 +2222,23 @@ async def handle_direct_setting_input(
|
||||
if not key:
|
||||
return
|
||||
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await message.answer("⚠️ Эта настройка доступна только для чтения.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
value = bot_configuration_service.parse_user_value(key, message.text or "")
|
||||
except ValueError as error:
|
||||
await message.answer(f"⚠️ {error}")
|
||||
return
|
||||
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError:
|
||||
await message.answer("⚠️ Эта настройка доступна только для чтения.")
|
||||
await state.clear()
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
@@ -2220,7 +2280,14 @@ async def reset_setting(
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
try:
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
except ReadOnlySettingError:
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
@@ -2260,9 +2327,16 @@ async def toggle_setting(
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
current = bot_configuration_service.get_current_value(key)
|
||||
new_value = not bool(current)
|
||||
await bot_configuration_service.set_value(db, key, new_value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, new_value)
|
||||
except ReadOnlySettingError:
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
@@ -2304,6 +2378,9 @@ async def apply_setting_choice(
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
value = bot_configuration_service.resolve_choice_token(key, choice_token)
|
||||
@@ -2311,7 +2388,11 @@ async def apply_setting_choice(
|
||||
await callback.answer("Это значение больше недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError:
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
|
||||
+249
-32
@@ -21,6 +21,25 @@ logger = logging.getLogger(__name__)
|
||||
PriceItem = Tuple[str, str, int]
|
||||
|
||||
|
||||
TRAFFIC_PACKAGE_FIELDS: Tuple[Tuple[int, str], ...] = (
|
||||
(5, "PRICE_TRAFFIC_5GB"),
|
||||
(10, "PRICE_TRAFFIC_10GB"),
|
||||
(25, "PRICE_TRAFFIC_25GB"),
|
||||
(50, "PRICE_TRAFFIC_50GB"),
|
||||
(100, "PRICE_TRAFFIC_100GB"),
|
||||
(250, "PRICE_TRAFFIC_250GB"),
|
||||
(500, "PRICE_TRAFFIC_500GB"),
|
||||
(1000, "PRICE_TRAFFIC_1000GB"),
|
||||
(0, "PRICE_TRAFFIC_UNLIMITED"),
|
||||
)
|
||||
|
||||
TRAFFIC_PACKAGE_FIELD_MAP: Dict[int, str] = {gb: field for gb, field in TRAFFIC_PACKAGE_FIELDS}
|
||||
TRAFFIC_PACKAGE_ORDER: Tuple[int, ...] = tuple(gb for gb, _ in TRAFFIC_PACKAGE_FIELDS)
|
||||
TRAFFIC_PACKAGE_ORDER_INDEX: Dict[int, int] = {
|
||||
gb: index for index, gb in enumerate(TRAFFIC_PACKAGE_ORDER)
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChoiceOption:
|
||||
value: Any
|
||||
@@ -178,6 +197,71 @@ SETTING_ENTRY_BY_KEY: Dict[str, SettingEntry] = {
|
||||
}
|
||||
|
||||
|
||||
def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]:
|
||||
order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"])
|
||||
if order_index is not None:
|
||||
return (0, order_index)
|
||||
return (1, package["gb"])
|
||||
|
||||
|
||||
def _collect_traffic_packages() -> List[Dict[str, Any]]:
|
||||
raw_packages = settings.get_traffic_packages()
|
||||
|
||||
packages_map: Dict[int, Dict[str, Any]] = {}
|
||||
for package in raw_packages:
|
||||
gb = int(package.get("gb", 0))
|
||||
packages_map[gb] = {
|
||||
"gb": gb,
|
||||
"price": int(package.get("price") or 0),
|
||||
"enabled": bool(package.get("enabled", True)),
|
||||
"field": TRAFFIC_PACKAGE_FIELD_MAP.get(gb),
|
||||
}
|
||||
|
||||
for gb, field in TRAFFIC_PACKAGE_FIELDS:
|
||||
if not hasattr(settings, field):
|
||||
continue
|
||||
|
||||
price = getattr(settings, field)
|
||||
existing = packages_map.get(gb)
|
||||
enabled = existing["enabled"] if existing is not None else True
|
||||
|
||||
packages_map[gb] = {
|
||||
"gb": gb,
|
||||
"price": int(price),
|
||||
"enabled": enabled,
|
||||
"field": field,
|
||||
}
|
||||
|
||||
packages = list(packages_map.values())
|
||||
packages.sort(key=_traffic_package_sort_key)
|
||||
return packages
|
||||
|
||||
|
||||
def _serialize_traffic_packages(packages: Iterable[Dict[str, Any]]) -> str:
|
||||
parts = []
|
||||
for package in packages:
|
||||
enabled_flag = "true" if package.get("enabled") else "false"
|
||||
parts.append(f"{int(package['gb'])}:{int(package['price'])}:{enabled_flag}")
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
async def _save_traffic_packages(
|
||||
db: AsyncSession,
|
||||
packages: Iterable[Dict[str, Any]],
|
||||
*,
|
||||
skip_if_same: bool = False,
|
||||
) -> bool:
|
||||
new_value = _serialize_traffic_packages(packages)
|
||||
current_value = bot_configuration_service.get_current_value("TRAFFIC_PACKAGES_CONFIG") or ""
|
||||
|
||||
if skip_if_same and current_value == new_value:
|
||||
return False
|
||||
|
||||
await bot_configuration_service.set_value(db, "TRAFFIC_PACKAGES_CONFIG", new_value)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def _language_code(language: str | None) -> str:
|
||||
return (language or "ru").split("-")[0].lower()
|
||||
|
||||
@@ -236,23 +320,17 @@ def _get_period_items(lang_code: str) -> List[PriceItem]:
|
||||
|
||||
|
||||
def _get_traffic_items(lang_code: str) -> List[PriceItem]:
|
||||
traffic_keys: Tuple[Tuple[int, str], ...] = (
|
||||
(5, "PRICE_TRAFFIC_5GB"),
|
||||
(10, "PRICE_TRAFFIC_10GB"),
|
||||
(25, "PRICE_TRAFFIC_25GB"),
|
||||
(50, "PRICE_TRAFFIC_50GB"),
|
||||
(100, "PRICE_TRAFFIC_100GB"),
|
||||
(250, "PRICE_TRAFFIC_250GB"),
|
||||
(500, "PRICE_TRAFFIC_500GB"),
|
||||
(1000, "PRICE_TRAFFIC_1000GB"),
|
||||
(0, "PRICE_TRAFFIC_UNLIMITED"),
|
||||
)
|
||||
packages = _collect_traffic_packages()
|
||||
|
||||
items: List[PriceItem] = []
|
||||
for gb, key in traffic_keys:
|
||||
if hasattr(settings, key):
|
||||
price = getattr(settings, key)
|
||||
items.append((key, _format_traffic_label(gb, lang_code), price))
|
||||
for package in packages:
|
||||
field = package.get("field")
|
||||
if not field:
|
||||
continue
|
||||
|
||||
label = _format_traffic_label(package["gb"], lang_code)
|
||||
icon = "✅" if package["enabled"] else "⚪️"
|
||||
items.append((field, f"{icon} {label}", int(package["price"])))
|
||||
return items
|
||||
|
||||
|
||||
@@ -285,17 +363,17 @@ def _build_period_summary(items: Iterable[PriceItem], lang_code: str, fallback:
|
||||
return ", ".join(parts) if parts else fallback
|
||||
|
||||
|
||||
def _build_traffic_summary(items: Iterable[PriceItem], lang_code: str, fallback: str) -> str:
|
||||
parts: List[str] = []
|
||||
for key, label, price in items:
|
||||
if key.endswith("UNLIMITED"):
|
||||
short_label = "∞"
|
||||
else:
|
||||
digits = ''.join(ch for ch in key if ch.isdigit())
|
||||
unit = "ГБ" if lang_code == "ru" else "GB"
|
||||
short_label = f"{digits}{unit}" if digits else label
|
||||
def _build_traffic_summary(lang_code: str, fallback: str) -> str:
|
||||
packages = _collect_traffic_packages()
|
||||
enabled_packages = [package for package in packages if package["enabled"]]
|
||||
|
||||
parts.append(f"{short_label}: {settings.format_price(price)}")
|
||||
if not enabled_packages:
|
||||
return fallback
|
||||
|
||||
parts: List[str] = []
|
||||
for package in enabled_packages:
|
||||
short_label = _format_traffic_label(package["gb"], lang_code, short=True)
|
||||
parts.append(f"{short_label}: {settings.format_price(int(package['price']))}")
|
||||
|
||||
return ", ".join(parts) if parts else fallback
|
||||
|
||||
@@ -407,6 +485,68 @@ def _build_settings_section(
|
||||
return "\n".join(lines).strip(), keyboard
|
||||
|
||||
|
||||
def _build_traffic_options_section(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
|
||||
texts = get_texts(language)
|
||||
lang_code = _language_code(language)
|
||||
packages = _collect_traffic_packages()
|
||||
|
||||
title = texts.t(
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE",
|
||||
"🚦 Отображение пакетов трафика",
|
||||
)
|
||||
|
||||
lines: List[str] = [title, ""]
|
||||
|
||||
enabled_labels = [
|
||||
_format_traffic_label(package["gb"], lang_code, short=True)
|
||||
for package in packages
|
||||
if package["enabled"]
|
||||
]
|
||||
|
||||
if enabled_labels:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE",
|
||||
"Активные пакеты: {items}",
|
||||
).format(items=", ".join(enabled_labels))
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE",
|
||||
"Активных пакетов нет.",
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT",
|
||||
"Нажмите на пакет, чтобы включить или выключить его отображение.",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
|
||||
buttons: List[types.InlineKeyboardButton] = []
|
||||
|
||||
for package in packages:
|
||||
icon = "✅" if package["enabled"] else "⚪️"
|
||||
label = _format_traffic_label(package["gb"], lang_code, short=True)
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{icon} {label}",
|
||||
callback_data=f"admin_pricing_toggle_traffic:{package['gb']}",
|
||||
)
|
||||
)
|
||||
|
||||
for i in range(0, len(buttons), 3):
|
||||
keyboard_rows.append(buttons[i : i + 3])
|
||||
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_pricing")])
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
return "\n".join(lines), keyboard
|
||||
|
||||
|
||||
def _build_period_options_section(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
|
||||
texts = get_texts(language)
|
||||
lang_code = _language_code(language)
|
||||
@@ -485,7 +625,7 @@ def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
|
||||
|
||||
fallback = texts.t("ADMIN_PRICING_SUMMARY_EMPTY", "—")
|
||||
summary_periods = _build_period_summary(period_items, lang_code, fallback)
|
||||
summary_traffic = _build_traffic_summary(traffic_items, lang_code, fallback)
|
||||
summary_traffic = _build_traffic_summary(lang_code, fallback)
|
||||
summary_extra = _build_extra_summary(extra_items, fallback)
|
||||
summary_trial = _format_trial_summary(lang_code)
|
||||
summary_core = _format_core_summary(lang_code)
|
||||
@@ -536,6 +676,13 @@ def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
|
||||
text=texts.t("ADMIN_PRICING_BUTTON_TRAFFIC", "📦 Пакеты трафика"),
|
||||
callback_data="admin_pricing_section:traffic",
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS",
|
||||
"🚦 Отображение пакетов",
|
||||
),
|
||||
callback_data="admin_pricing_section:traffic_options",
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PRICING_BUTTON_EXTRA", "➕ Дополнительно"),
|
||||
callback_data="admin_pricing_section:extra",
|
||||
@@ -564,6 +711,8 @@ def _build_section(
|
||||
elif section == "extra":
|
||||
items = _get_extra_items(lang_code)
|
||||
title = texts.t("ADMIN_PRICING_SECTION_EXTRA_TITLE", "➕ Дополнительные опции")
|
||||
elif section == "traffic_options":
|
||||
return _build_traffic_options_section(language)
|
||||
elif section in SETTING_ENTRIES_BY_SECTION:
|
||||
return _build_settings_section(section, language)
|
||||
elif section == "period_options":
|
||||
@@ -922,14 +1071,27 @@ async def process_pricing_input(
|
||||
await bot_configuration_service.set_value(db, key, new_value)
|
||||
await db.commit()
|
||||
|
||||
if key.startswith("PRICE_TRAFFIC_"):
|
||||
packages = _collect_traffic_packages()
|
||||
await _save_traffic_packages(db, packages, skip_if_same=True)
|
||||
|
||||
section_text, section_keyboard = _build_section(section, db_user.language)
|
||||
|
||||
if mode == "price":
|
||||
label = _resolve_label(section, key, db_user.language)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_PRICING_EDIT_SUCCESS", "Цена для {item} обновлена: {price}").format(
|
||||
item=label,
|
||||
price=settings.format_price(int(new_value)),
|
||||
if message_id:
|
||||
await _render_message_by_id(
|
||||
message.bot,
|
||||
message.chat.id,
|
||||
message_id,
|
||||
section_text,
|
||||
section_keyboard,
|
||||
)
|
||||
)
|
||||
try:
|
||||
await message.delete()
|
||||
except TelegramBadRequest as error:
|
||||
logger.debug("Failed to delete pricing input message: %s", error)
|
||||
await state.clear()
|
||||
return
|
||||
else:
|
||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||
lang_code = _language_code(db_user.language)
|
||||
@@ -1043,6 +1205,57 @@ async def select_setting_choice(
|
||||
await _render_message(callback.message, text, keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_traffic_package(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
try:
|
||||
_, gb_raw = callback.data.split(":", 1)
|
||||
gb_value = int(gb_raw)
|
||||
except (ValueError, TypeError):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
packages = _collect_traffic_packages()
|
||||
|
||||
target_index = next((index for index, pkg in enumerate(packages) if pkg["gb"] == gb_value), None)
|
||||
if target_index is None:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
enabled_count = sum(1 for pkg in packages if pkg["enabled"])
|
||||
target_package = packages[target_index]
|
||||
|
||||
if target_package["enabled"] and enabled_count <= 1:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN",
|
||||
"Должен оставаться хотя бы один пакет.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
target_package["enabled"] = not target_package["enabled"]
|
||||
|
||||
await _save_traffic_packages(db, packages)
|
||||
|
||||
status_text = (
|
||||
texts.t("ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED", "Пакет включен.")
|
||||
if target_package["enabled"]
|
||||
else texts.t("ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED", "Пакет отключен.")
|
||||
)
|
||||
await callback.answer(status_text)
|
||||
|
||||
text, keyboard = _build_traffic_options_section(db_user.language)
|
||||
await _render_message(callback.message, text, keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_period_option(
|
||||
@@ -1127,6 +1340,10 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
select_setting_choice,
|
||||
F.data.startswith("admin_pricing_choice:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_traffic_package,
|
||||
F.data.startswith("admin_pricing_toggle_traffic:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_period_option,
|
||||
F.data.startswith("admin_pricing_toggle_period:"),
|
||||
|
||||
@@ -68,7 +68,7 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
|
||||
@@ -105,7 +105,7 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<i>🕐 Время: {current_time}</i>
|
||||
@@ -190,7 +190,7 @@ async def show_referral_settings(
|
||||
• Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
• Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
• Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
|
||||
<b>Комиссионные:</b>
|
||||
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import logging
|
||||
import math
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from app.states import SquadRenameStates, SquadCreateStates
|
||||
from app.states import SquadRenameStates, SquadCreateStates, SquadMigrationStates
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.database.crud.server_squad import (
|
||||
count_active_users_for_squad,
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_uuid,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_remnawave_keyboard, get_sync_options_keyboard,
|
||||
get_node_management_keyboard, get_confirmation_keyboard,
|
||||
get_squad_management_keyboard, get_squad_edit_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.remnawave_service import RemnaWaveService, RemnaWaveConfigurationError
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_bytes, format_datetime
|
||||
|
||||
@@ -21,6 +27,769 @@ logger = logging.getLogger(__name__)
|
||||
squad_inbound_selections = {}
|
||||
squad_create_data = {}
|
||||
|
||||
MIGRATION_PAGE_SIZE = 8
|
||||
|
||||
|
||||
def _format_migration_server_label(texts, server) -> str:
|
||||
status = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE", "✅ Доступен")
|
||||
if getattr(server, "is_available", True)
|
||||
else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE", "🚫 Недоступен")
|
||||
)
|
||||
return texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SERVER_LABEL",
|
||||
"{name} — 👥 {users} ({status})",
|
||||
).format(name=server.display_name, users=server.current_users, status=status)
|
||||
|
||||
|
||||
def _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page: int,
|
||||
total_pages: int,
|
||||
stage: str,
|
||||
*,
|
||||
exclude_uuid: str = None,
|
||||
):
|
||||
prefix = "admin_migration_source" if stage == "source" else "admin_migration_target"
|
||||
rows = []
|
||||
has_items = False
|
||||
|
||||
button_template = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON",
|
||||
"🌍 {name} — 👥 {users} ({status})",
|
||||
)
|
||||
|
||||
for squad in squads:
|
||||
if exclude_uuid and squad.squad_uuid == exclude_uuid:
|
||||
continue
|
||||
|
||||
has_items = True
|
||||
status = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT", "✅")
|
||||
if getattr(squad, "is_available", True)
|
||||
else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT", "🚫")
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=button_template.format(
|
||||
name=squad.display_name,
|
||||
users=squad.current_users,
|
||||
status=status,
|
||||
),
|
||||
callback_data=f"{prefix}_{squad.squad_uuid}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
if page > 1:
|
||||
nav_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"{prefix}_page_{page - 1}",
|
||||
)
|
||||
)
|
||||
nav_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE",
|
||||
"Стр. {page}/{pages}",
|
||||
).format(page=page, pages=total_pages),
|
||||
callback_data="admin_migration_page_info",
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
nav_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"{prefix}_page_{page + 1}",
|
||||
)
|
||||
)
|
||||
rows.append(nav_buttons)
|
||||
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.CANCEL,
|
||||
callback_data="admin_migration_cancel",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=rows), has_items
|
||||
|
||||
|
||||
async def _fetch_migration_page(
|
||||
db: AsyncSession,
|
||||
page: int,
|
||||
):
|
||||
squads, total = await get_all_server_squads(
|
||||
db,
|
||||
page=max(1, page),
|
||||
limit=MIGRATION_PAGE_SIZE,
|
||||
)
|
||||
total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE))
|
||||
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
squads, total = await get_all_server_squads(
|
||||
db,
|
||||
page=page,
|
||||
limit=MIGRATION_PAGE_SIZE,
|
||||
)
|
||||
total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE))
|
||||
|
||||
return squads, page, total_pages
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_squad_migration_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await state.clear()
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=1)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"source",
|
||||
)
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE",
|
||||
"Выберите сквад, из которого нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS",
|
||||
"Нет доступных сквадов. Добавьте новые или отмените операцию.",
|
||||
)
|
||||
)
|
||||
|
||||
await state.set_state(SquadMigrationStates.selecting_source)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def paginate_migration_source(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
if await state.get_state() != SquadMigrationStates.selecting_source:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(callback.data.split("_page_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=page)
|
||||
texts = get_texts(db_user.language)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"source",
|
||||
)
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE",
|
||||
"Выберите сквад, из которого нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS",
|
||||
"Нет доступных сквадов. Добавьте новые или отмените операцию.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_migration_source_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
if await state.get_state() != SquadMigrationStates.selecting_source:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if "_page_" in callback.data:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
source_uuid = callback.data.replace("admin_migration_source_", "", 1)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
server = await get_server_squad_by_uuid(db, source_uuid)
|
||||
|
||||
if not server:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND",
|
||||
"Сквад не найден или недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
source_uuid=server.squad_uuid,
|
||||
source_display=_format_migration_server_label(texts, server),
|
||||
)
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=1)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"target",
|
||||
exclude_uuid=server.squad_uuid,
|
||||
)
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
|
||||
"Источник: {source}",
|
||||
).format(source=_format_migration_server_label(texts, server))
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
|
||||
"Выберите сквад, в который нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
|
||||
"Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
)
|
||||
)
|
||||
|
||||
await state.set_state(SquadMigrationStates.selecting_target)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def paginate_migration_target(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
if await state.get_state() != SquadMigrationStates.selecting_target:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(callback.data.split("_page_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
if not source_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=page)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"target",
|
||||
exclude_uuid=source_uuid,
|
||||
)
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
|
||||
"Источник: {source}",
|
||||
).format(source=source_display)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
|
||||
"Выберите сквад, в который нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
|
||||
"Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_migration_target_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
if current_state != SquadMigrationStates.selecting_target:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if "_page_" in callback.data:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
|
||||
if not source_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
target_uuid = callback.data.replace("admin_migration_target_", "", 1)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if target_uuid == source_uuid:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SAME_SQUAD",
|
||||
"Нельзя выбрать тот же сквад.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
target_server = await get_server_squad_by_uuid(db, target_uuid)
|
||||
if not target_server:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND",
|
||||
"Сквад не найден или недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
|
||||
users_to_move = await count_active_users_for_squad(db, source_uuid)
|
||||
|
||||
await state.update_data(
|
||||
target_uuid=target_server.squad_uuid,
|
||||
target_display=_format_migration_server_label(texts, target_server),
|
||||
migration_count=users_to_move,
|
||||
)
|
||||
|
||||
await state.set_state(SquadMigrationStates.confirming)
|
||||
|
||||
message_lines = [
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>"),
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS",
|
||||
"Проверьте параметры переезда:",
|
||||
),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE",
|
||||
"• Из: {source}",
|
||||
).format(source=source_display),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET",
|
||||
"• В: {target}",
|
||||
).format(target=_format_migration_server_label(texts, target_server)),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT",
|
||||
"• Пользователей к переносу: {count}",
|
||||
).format(count=users_to_move),
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT",
|
||||
"Подтвердите выполнение операции.",
|
||||
),
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON",
|
||||
"✅ Подтвердить",
|
||||
),
|
||||
callback_data="admin_migration_confirm",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CHANGE_TARGET",
|
||||
"🔄 Изменить сервер назначения",
|
||||
),
|
||||
callback_data="admin_migration_change_target",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.CANCEL,
|
||||
callback_data="admin_migration_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def change_migration_target(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
|
||||
if not source_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await state.set_state(SquadMigrationStates.selecting_target)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=1)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"target",
|
||||
exclude_uuid=source_uuid,
|
||||
)
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
|
||||
"Источник: {source}",
|
||||
).format(source=source_display)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
|
||||
"Выберите сквад, в который нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
|
||||
"Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_squad_migration(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
if current_state != SquadMigrationStates.confirming:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
target_uuid = data.get("target_uuid")
|
||||
|
||||
if not source_uuid or not target_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
await callback.answer(texts.t("ADMIN_SQUAD_MIGRATION_IN_PROGRESS", "Запускаю переезд..."))
|
||||
|
||||
try:
|
||||
result = await remnawave_service.migrate_squad_users(
|
||||
db,
|
||||
source_uuid=source_uuid,
|
||||
target_uuid=target_uuid,
|
||||
)
|
||||
except RemnaWaveConfigurationError as error:
|
||||
message = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_API_ERROR",
|
||||
"❌ RemnaWave API не настроен: {error}",
|
||||
).format(error=str(error))
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
target_display = data.get("target_display") or target_uuid
|
||||
|
||||
if not result.get("success"):
|
||||
error_message = result.get("message") or ""
|
||||
error_code = result.get("error") or "unexpected"
|
||||
message = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_ERROR",
|
||||
"❌ Не удалось выполнить переезд (код: {code}). {details}",
|
||||
).format(code=error_code, details=error_message)
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON",
|
||||
"🔁 Новый переезд",
|
||||
),
|
||||
callback_data="admin_rw_migration",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
message_lines = [
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE", "✅ Переезд завершен"),
|
||||
"",
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE", "• Из: {source}").format(
|
||||
source=source_display
|
||||
),
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET", "• В: {target}").format(
|
||||
target=target_display
|
||||
),
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_TOTAL",
|
||||
"Найдено подписок: {count}",
|
||||
).format(count=result.get("total", 0)),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_UPDATED",
|
||||
"Перенесено: {count}",
|
||||
).format(count=result.get("updated", 0)),
|
||||
]
|
||||
|
||||
panel_updated = result.get("panel_updated", 0)
|
||||
panel_failed = result.get("panel_failed", 0)
|
||||
|
||||
if panel_updated:
|
||||
message_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED",
|
||||
"Обновлено в панели: {count}",
|
||||
).format(count=panel_updated)
|
||||
)
|
||||
if panel_failed:
|
||||
message_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED",
|
||||
"Не удалось обновить в панели: {count}",
|
||||
).format(count=panel_failed)
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON",
|
||||
"🔁 Новый переезд",
|
||||
),
|
||||
callback_data="admin_rw_migration",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_squad_migration(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
await state.clear()
|
||||
|
||||
message = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CANCELLED",
|
||||
"❌ Переезд отменен.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_migration_page_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_PAGE_HINT", "Это текущая страница."),
|
||||
show_alert=False,
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_remnawave_menu(
|
||||
@@ -1920,6 +2689,15 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes")
|
||||
dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync")
|
||||
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
|
||||
dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
|
||||
dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
|
||||
dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_"))
|
||||
dp.callback_query.register(paginate_migration_target, F.data.startswith("admin_migration_target_page_"))
|
||||
dp.callback_query.register(handle_migration_target_selection, F.data.startswith("admin_migration_target_"))
|
||||
dp.callback_query.register(change_migration_target, F.data == "admin_migration_change_target")
|
||||
dp.callback_query.register(confirm_squad_migration, F.data == "admin_migration_confirm")
|
||||
dp.callback_query.register(cancel_squad_migration, F.data == "admin_migration_cancel")
|
||||
dp.callback_query.register(handle_migration_page_info, F.data == "admin_migration_page_info")
|
||||
dp.callback_query.register(show_squads_management, F.data == "admin_rw_squads")
|
||||
dp.callback_query.register(show_squad_details, F.data.startswith("admin_squad_manage_"))
|
||||
dp.callback_query.register(manage_squad_action, F.data.startswith("squad_add_users_"))
|
||||
|
||||
@@ -35,6 +35,8 @@ def _build_server_edit_view(server):
|
||||
else "Не выбраны"
|
||||
)
|
||||
|
||||
trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
@@ -51,6 +53,7 @@ def _build_server_edit_view(server):
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
• Промогруппы: {promo_groups_text}
|
||||
• Выдача триала: {trial_status}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
@@ -80,6 +83,12 @@ def _build_server_edit_view(server):
|
||||
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎁 Выдавать сквад" if not server.is_trial_eligible else "🚫 Не выдавать сквад",
|
||||
callback_data=f"admin_server_trial_{server.id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
|
||||
@@ -527,6 +536,38 @@ async def toggle_server_availability(
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_trial_assignment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not server.is_trial_eligible
|
||||
await update_server_squad(db, server_id, is_trial_eligible=new_status)
|
||||
|
||||
status_text = "будет выдаваться" if new_status else "перестанет выдаваться"
|
||||
await callback.answer(f"✅ Сквад {status_text} в триал")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_price(
|
||||
@@ -1255,6 +1296,7 @@ def register_handlers(dp: Dispatcher):
|
||||
& ~F.data.contains("promo"),
|
||||
)
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
dp.callback_query.register(toggle_server_trial_assignment, F.data.startswith("admin_server_trial_"))
|
||||
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
|
||||
@@ -371,7 +371,7 @@ async def handle_admin_ticket_reply(
|
||||
return
|
||||
|
||||
# Обычный режим ответа админа
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
|
||||
if not ticket:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
@@ -859,52 +859,71 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s
|
||||
except Exception:
|
||||
pass
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
# Получаем тикет с пользователем
|
||||
ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True)
|
||||
if not ticket_with_user or not ticket_with_user.user:
|
||||
|
||||
# Обеспечим наличие данных пользователя в объекте тикета
|
||||
ticket_with_user = ticket
|
||||
if not getattr(ticket_with_user, "user", None):
|
||||
ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True)
|
||||
|
||||
user = getattr(ticket_with_user, "user", None)
|
||||
if not user:
|
||||
logger.error(f"User not found for ticket #{ticket.id}")
|
||||
return
|
||||
|
||||
texts = get_texts(ticket_with_user.user.language)
|
||||
|
||||
|
||||
if not getattr(user, "telegram_id", None):
|
||||
logger.error(
|
||||
"Cannot notify ticket #%s user without telegram_id (username=%s)",
|
||||
ticket.id,
|
||||
getattr(user, "username", None),
|
||||
)
|
||||
return
|
||||
|
||||
chat_id = int(user.telegram_id)
|
||||
texts = get_texts(user.language)
|
||||
|
||||
# Формируем уведомление
|
||||
base_text = texts.t(
|
||||
"TICKET_REPLY_NOTIFICATION",
|
||||
"TICKET_REPLY_NOTIFICATION",
|
||||
"🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:"
|
||||
).format(
|
||||
ticket_id=ticket.id,
|
||||
reply_preview=reply_text[:100] + "..." if len(reply_text) > 100 else reply_text
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
|
||||
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
|
||||
])
|
||||
|
||||
# Если было фото в последнем ответе админа — отправим как фото
|
||||
last_message = await TicketMessageCRUD.get_last_message(db, ticket.id)
|
||||
if last_message and last_message.has_media and last_message.media_type == "photo" and last_message.is_from_admin:
|
||||
caption = base_text
|
||||
try:
|
||||
await bot.send_photo(
|
||||
chat_id=ticket_with_user.user.telegram_id,
|
||||
chat_id=chat_id,
|
||||
photo=last_message.media_file_id,
|
||||
caption=caption,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
|
||||
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
|
||||
])
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
except TelegramBadRequest as photo_error:
|
||||
logger.error(
|
||||
"Не удалось отправить фото-уведомление пользователю %s для тикета %s: %s",
|
||||
chat_id,
|
||||
ticket.id,
|
||||
photo_error,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить фото-уведомление: {e}")
|
||||
# Фоллбек: текстовое уведомление
|
||||
await bot.send_message(
|
||||
chat_id=ticket_with_user.user.telegram_id,
|
||||
chat_id=chat_id,
|
||||
text=base_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
|
||||
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
|
||||
])
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
logger.info(f"Ticket #{ticket.id} reply notification sent to user {ticket_with_user.user.telegram_id}")
|
||||
|
||||
|
||||
logger.info(f"Ticket #{ticket.id} reply notification sent to user {chat_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error notifying user about ticket reply: {e}")
|
||||
|
||||
|
||||
+54
-13
@@ -2080,11 +2080,16 @@ async def extend_user_subscription(
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⏰ <b>Продление подписки</b>\n\n"
|
||||
"Введите количество дней для продления:\n"
|
||||
"• Например: 30, 7, 90\n"
|
||||
"• Максимум: 365 дней\n\n"
|
||||
"Введите количество дней для изменения:\n"
|
||||
"• Положительные значения продлят подписку\n"
|
||||
"• Отрицательные сократят срок подписки\n"
|
||||
"• Диапазон: от -365 до 365 дней (0 недопустимо)\n\n"
|
||||
"Или нажмите /cancel для отмены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="-7 дней", callback_data=f"admin_sub_extend_days_{user_id}_-7"),
|
||||
types.InlineKeyboardButton(text="-30 дней", callback_data=f"admin_sub_extend_days_{user_id}_-30")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="7 дней", callback_data=f"admin_sub_extend_days_{user_id}_7"),
|
||||
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_extend_days_{user_id}_30")
|
||||
@@ -2114,11 +2119,19 @@ async def process_subscription_extension_days(
|
||||
user_id = int(parts[-2])
|
||||
days = int(parts[-1])
|
||||
|
||||
if days == 0 or days < -365 or days > 365:
|
||||
await callback.answer("❌ Количество дней должно быть от -365 до 365, исключая 0", show_alert=True)
|
||||
return
|
||||
|
||||
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
|
||||
|
||||
|
||||
if success:
|
||||
if days > 0:
|
||||
action_text = f"продлена на {days} дней"
|
||||
else:
|
||||
action_text = f"уменьшена на {abs(days)} дней"
|
||||
await callback.message.edit_text(
|
||||
f"✅ Подписка пользователя продлена на {days} дней",
|
||||
f"✅ Подписка пользователя {action_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
@@ -2153,15 +2166,19 @@ async def process_subscription_extension_text(
|
||||
try:
|
||||
days = int(message.text.strip())
|
||||
|
||||
if days <= 0 or days > 365:
|
||||
await message.answer("❌ Количество дней должно быть от 1 до 365")
|
||||
if days == 0 or days < -365 or days > 365:
|
||||
await message.answer("❌ Количество дней должно быть от -365 до 365, исключая 0")
|
||||
return
|
||||
|
||||
|
||||
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
|
||||
|
||||
|
||||
if success:
|
||||
if days > 0:
|
||||
action_text = f"продлена на {days} дней"
|
||||
else:
|
||||
action_text = f"уменьшена на {abs(days)} дней"
|
||||
await message.answer(
|
||||
f"✅ Подписка пользователя продлена на {days} дней",
|
||||
f"✅ Подписка пользователя {action_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
@@ -3115,9 +3132,12 @@ async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
|
||||
if days > 0:
|
||||
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
|
||||
else:
|
||||
logger.info(f"Админ {admin_id} сократил подписку пользователя {user_id} на {abs(days)} дней")
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка продления подписки: {e}")
|
||||
return False
|
||||
@@ -3240,13 +3260,34 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
logger.error(f"У пользователя {user_id} уже есть подписка")
|
||||
return False
|
||||
|
||||
trial_squads: list[str] = []
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось подобрать сквад при выдаче подписки админом %s: %s",
|
||||
admin_id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.DEFAULT_DEVICE_LIMIT,
|
||||
connected_squads=[settings.TRIAL_SQUAD_UUID] if settings.TRIAL_SQUAD_UUID else []
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
+26
-3
@@ -569,10 +569,33 @@ async def process_topup_amount(
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
try:
|
||||
amount_rubles = float(message.text.replace(',', '.'))
|
||||
|
||||
if not message.text:
|
||||
if message.successful_payment:
|
||||
logger.info(
|
||||
"Получено сообщение об успешном платеже без текста, "
|
||||
"обработчик суммы пополнения завершает работу"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
amount_text = message.text.strip()
|
||||
if not amount_text:
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
amount_rubles = float(amount_text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer("Минимальная сумма пополнения: 1 ₽")
|
||||
return
|
||||
|
||||
+271
-1
@@ -1,5 +1,7 @@
|
||||
import html
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -8,13 +10,18 @@ from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.database.crud.promo_group import (
|
||||
get_auto_assign_promo_groups,
|
||||
has_auto_assign_promo_groups,
|
||||
)
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.keyboards.inline import (
|
||||
get_main_menu_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_info_menu_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.database.models import User
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
@@ -22,6 +29,7 @@ from app.services.subscription_checkout_service import (
|
||||
)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
@@ -29,10 +37,109 @@ from app.utils.promo_offer import (
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_rubles(amount_kopeks: int) -> str:
|
||||
rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
|
||||
if rubles == rubles.to_integral_value():
|
||||
formatted = f"{rubles:,.0f}"
|
||||
else:
|
||||
formatted = f"{rubles:,.2f}"
|
||||
|
||||
return f"{formatted.replace(',', ' ')} ₽"
|
||||
|
||||
|
||||
def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]:
|
||||
discounts: Dict[int, int] = {}
|
||||
raw_discounts = getattr(group, "period_discounts", None)
|
||||
|
||||
if isinstance(raw_discounts, dict):
|
||||
for key, value in raw_discounts.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized_percent = max(0, min(100, percent))
|
||||
if normalized_percent > 0:
|
||||
discounts[period] = normalized_percent
|
||||
|
||||
if group.is_default and settings.is_base_promo_group_period_discount_enabled():
|
||||
try:
|
||||
base_discounts = settings.get_base_promo_group_period_discounts() or {}
|
||||
except Exception:
|
||||
base_discounts = {}
|
||||
|
||||
for key, value in base_discounts.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if period in discounts:
|
||||
continue
|
||||
|
||||
normalized_percent = max(0, min(100, percent))
|
||||
if normalized_percent > 0:
|
||||
discounts[period] = normalized_percent
|
||||
|
||||
return dict(sorted(discounts.items()))
|
||||
|
||||
|
||||
def _build_group_discount_lines(group: PromoGroup, texts, language: str) -> list[str]:
|
||||
lines: list[str] = []
|
||||
|
||||
if getattr(group, "server_discount_percent", 0) > 0:
|
||||
lines.append(
|
||||
texts.t("PROMO_GROUP_DISCOUNT_SERVERS", "🌍 Серверы: {percent}%").format(
|
||||
percent=group.server_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
if getattr(group, "traffic_discount_percent", 0) > 0:
|
||||
lines.append(
|
||||
texts.t("PROMO_GROUP_DISCOUNT_TRAFFIC", "📊 Трафик: {percent}%").format(
|
||||
percent=group.traffic_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
if getattr(group, "device_discount_percent", 0) > 0:
|
||||
lines.append(
|
||||
texts.t("PROMO_GROUP_DISCOUNT_DEVICES", "📱 Доп. устройства: {percent}%").format(
|
||||
percent=group.device_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
period_discounts = _collect_period_discounts(group)
|
||||
|
||||
if period_discounts:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER",
|
||||
"⏳ Скидки за длительный период:",
|
||||
)
|
||||
)
|
||||
|
||||
for period_days, percent in period_discounts.items():
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM",
|
||||
"{period} — {percent}%",
|
||||
).format(
|
||||
period=format_period_description(period_days, language),
|
||||
percent=percent,
|
||||
)
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -61,6 +168,13 @@ async def show_main_menu(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
@@ -74,6 +188,7 @@ async def show_main_menu(
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -117,6 +232,7 @@ async def show_info_menu(
|
||||
privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language)
|
||||
public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language)
|
||||
faq_enabled = await FaqService.is_enabled(db, db_user.language)
|
||||
promo_groups_available = await has_auto_assign_promo_groups(db)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
@@ -126,12 +242,153 @@ async def show_info_menu(
|
||||
show_privacy_policy=privacy_enabled,
|
||||
show_public_offer=public_offer_enabled,
|
||||
show_faq=faq_enabled,
|
||||
show_promo_groups=promo_groups_available,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_promo_groups_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
promo_groups = await get_auto_assign_promo_groups(db)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]]
|
||||
)
|
||||
|
||||
if not promo_groups:
|
||||
empty_text = texts.t(
|
||||
"PROMO_GROUPS_INFO_EMPTY",
|
||||
"Промогруппы с автовыдачей ещё не настроены.",
|
||||
)
|
||||
header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 <b>Промогруппы</b>")
|
||||
message = f"{header}\n\n{empty_text}" if empty_text else header
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
total_spent_kopeks = await get_user_total_spent_kopeks(db, db_user.id)
|
||||
total_spent_text = _format_rubles(total_spent_kopeks)
|
||||
|
||||
sorted_groups = sorted(
|
||||
promo_groups,
|
||||
key=lambda group: (group.auto_assign_total_spent_kopeks or 0, group.id),
|
||||
)
|
||||
|
||||
achieved_groups: List[PromoGroup] = [
|
||||
group
|
||||
for group in sorted_groups
|
||||
if (group.auto_assign_total_spent_kopeks or 0) > 0
|
||||
and total_spent_kopeks >= (group.auto_assign_total_spent_kopeks or 0)
|
||||
]
|
||||
|
||||
current_group = next(
|
||||
(group for group in sorted_groups if group.id == db_user.promo_group_id),
|
||||
None,
|
||||
)
|
||||
|
||||
if not current_group and achieved_groups:
|
||||
current_group = achieved_groups[-1]
|
||||
|
||||
next_group = next(
|
||||
(
|
||||
group
|
||||
for group in sorted_groups
|
||||
if (group.auto_assign_total_spent_kopeks or 0) > total_spent_kopeks
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 <b>Промогруппы</b>")
|
||||
lines: List[str] = [header, ""]
|
||||
|
||||
spent_line = texts.t(
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT",
|
||||
"💰 Потрачено в боте: {amount}",
|
||||
).format(amount=total_spent_text)
|
||||
lines.append(spent_line)
|
||||
|
||||
if current_group:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL",
|
||||
"🏆 Текущий уровень: {name}",
|
||||
).format(name=html.escape(current_group.name)),
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL",
|
||||
"🏆 Текущий уровень: пока не получен",
|
||||
)
|
||||
)
|
||||
|
||||
if next_group:
|
||||
remaining_kopeks = (next_group.auto_assign_total_spent_kopeks or 0) - total_spent_kopeks
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL",
|
||||
"📈 До уровня «{name}»: осталось {amount}",
|
||||
).format(
|
||||
name=html.escape(next_group.name),
|
||||
amount=_format_rubles(max(remaining_kopeks, 0)),
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL",
|
||||
"🏆 Вы уже получили максимальный уровень скидок!",
|
||||
)
|
||||
)
|
||||
|
||||
lines.extend(["", texts.t("PROMO_GROUPS_INFO_LEVELS_HEADER", "📋 Уровни с автовыдачей:")])
|
||||
|
||||
for group in sorted_groups:
|
||||
threshold = group.auto_assign_total_spent_kopeks or 0
|
||||
status_icon = "✅" if total_spent_kopeks >= threshold else "🔒"
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE",
|
||||
"{status} <b>{name}</b> — от {amount}",
|
||||
).format(
|
||||
status=status_icon,
|
||||
name=html.escape(group.name),
|
||||
amount=_format_rubles(threshold),
|
||||
)
|
||||
)
|
||||
|
||||
discount_lines = _build_group_discount_lines(group, texts, db_user.language)
|
||||
for discount_line in discount_lines:
|
||||
if discount_line:
|
||||
lines.append(f" {discount_line}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
|
||||
message_text = "\n".join(lines)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_faq_pages(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -623,6 +880,13 @@ async def handle_back_to_menu(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
@@ -636,6 +900,7 @@ async def handle_back_to_menu(
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -782,6 +1047,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "menu_info",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promo_groups_info,
|
||||
F.data == "menu_info_promo_groups",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_faq_pages,
|
||||
F.data == "menu_faq",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
@@ -114,6 +116,8 @@ async def handle_successful_payment(
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
amount_text = settings.format_price(amount_kopeks).replace(" ₽", "")
|
||||
|
||||
keyboard = await payment_service.build_topup_success_keyboard(user)
|
||||
|
||||
@@ -129,7 +133,7 @@ async def handle_successful_payment(
|
||||
"Спасибо за пополнение! 🚀",
|
||||
).format(
|
||||
stars_spent=payment.total_amount,
|
||||
amount=int(rubles_amount),
|
||||
amount=amount_text,
|
||||
transaction_id=transaction_id_short,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
@@ -137,8 +141,10 @@ async def handle_successful_payment(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}₽"
|
||||
"✅ Stars платеж успешно обработан: пользователь %s, %s звезд → %s",
|
||||
user.id,
|
||||
payment.total_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
else:
|
||||
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
|
||||
|
||||
@@ -31,12 +31,14 @@ from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -333,6 +335,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
@@ -344,6 +353,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -362,6 +372,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
from sqlalchemy import delete
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
@@ -746,6 +757,13 @@ async def complete_registration_from_callback(
|
||||
and SupportSettingsService.is_moderator(existing_user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
@@ -758,6 +776,7 @@ async def complete_registration_from_callback(
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -918,6 +937,13 @@ async def complete_registration_from_callback(
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
@@ -930,6 +956,7 @@ async def complete_registration_from_callback(
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -984,6 +1011,13 @@ async def complete_registration(
|
||||
and SupportSettingsService.is_moderator(existing_user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
menu_text,
|
||||
@@ -996,6 +1030,7 @@ async def complete_registration(
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -1156,6 +1191,13 @@ async def complete_registration(
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
menu_text,
|
||||
@@ -1168,6 +1210,7 @@ async def complete_registration(
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -1431,6 +1474,13 @@ async def required_sub_channel_check(
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
@@ -1440,6 +1490,7 @@ async def required_sub_channel_check(
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
|
||||
@@ -907,18 +907,34 @@ async def show_trial_offer(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
trial_server_name = "🎯 Тестовый сервер"
|
||||
trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_squad_by_uuid,
|
||||
get_trial_eligible_server_squads,
|
||||
)
|
||||
|
||||
if settings.TRIAL_SQUAD_UUID:
|
||||
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
|
||||
|
||||
if trial_squads:
|
||||
if len(trial_squads) == 1:
|
||||
trial_server_name = trial_squads[0].display_name
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
"TRIAL_SERVER_RANDOM_POOL",
|
||||
"🎲 Случайный из {count} серверов",
|
||||
).format(count=len(trial_squads))
|
||||
elif settings.TRIAL_SQUAD_UUID:
|
||||
trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID)
|
||||
if trial_server:
|
||||
trial_server_name = trial_server.display_name
|
||||
else:
|
||||
logger.warning(f"Триальный сервер с UUID {settings.TRIAL_SQUAD_UUID} не найден в БД")
|
||||
logger.warning(
|
||||
"Триальный сервер с UUID %s не найден в БД",
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
else:
|
||||
logger.warning("TRIAL_SQUAD_UUID не настроен в конфигурации")
|
||||
logger.warning("Не настроены сквады для выдачи триалов")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения триального сервера: {e}")
|
||||
@@ -2501,9 +2517,20 @@ async def handle_extend_subscription(
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period
|
||||
|
||||
total_original_price = (
|
||||
base_price_original
|
||||
+ servers_price_per_month * months_in_period
|
||||
+ devices_price_per_month * months_in_period
|
||||
+ traffic_price_per_month * months_in_period
|
||||
)
|
||||
|
||||
price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
promo_component = _apply_promo_offer_discount(db_user, price)
|
||||
renewal_prices[days] = promo_component["discounted"]
|
||||
|
||||
renewal_prices[days] = {
|
||||
"final": promo_component["discounted"],
|
||||
"original": total_original_price,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка расчета цены для периода {days}: {e}")
|
||||
@@ -2516,9 +2543,35 @@ async def handle_extend_subscription(
|
||||
prices_text = ""
|
||||
|
||||
for days in available_periods:
|
||||
if days in renewal_prices:
|
||||
period_display = format_period_description(days, db_user.language)
|
||||
prices_text += f"📅 {period_display} - {texts.format_price(renewal_prices[days])}\n"
|
||||
if days not in renewal_prices:
|
||||
continue
|
||||
|
||||
price_info = renewal_prices[days]
|
||||
|
||||
if isinstance(price_info, dict):
|
||||
final_price = price_info.get("final")
|
||||
if final_price is None:
|
||||
final_price = price_info.get("original", 0)
|
||||
original_price = price_info.get("original", final_price)
|
||||
else:
|
||||
final_price = price_info
|
||||
original_price = final_price
|
||||
|
||||
has_discount = original_price > final_price
|
||||
|
||||
period_display = format_period_description(days, db_user.language)
|
||||
|
||||
if has_discount:
|
||||
prices_text += (
|
||||
"📅 "
|
||||
f"{period_display} - <s>{texts.format_price(original_price)}</s> "
|
||||
f"{texts.format_price(final_price)}\n"
|
||||
)
|
||||
else:
|
||||
prices_text += (
|
||||
"📅 "
|
||||
f"{period_display} - {texts.format_price(final_price)}\n"
|
||||
)
|
||||
|
||||
promo_discounts_text = _build_promo_group_discount_text(
|
||||
db_user,
|
||||
@@ -4290,7 +4343,8 @@ async def create_paid_subscription_with_traffic_mode(
|
||||
duration_days=duration_days,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=False,
|
||||
)
|
||||
|
||||
logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
|
||||
|
||||
@@ -674,6 +674,12 @@ def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="admin_rw_squads"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_REMNAWAVE_MIGRATION", "🚚 Переезд"),
|
||||
callback_data="admin_rw_migration"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_REMNAWAVE_TRAFFIC", "📈 Трафик"),
|
||||
|
||||
+34
-8
@@ -121,6 +121,7 @@ def get_main_menu_keyboard(
|
||||
show_resume_checkout: bool = False,
|
||||
*,
|
||||
is_moderator: bool = False,
|
||||
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -230,6 +231,11 @@ def get_main_menu_keyboard(
|
||||
)
|
||||
])
|
||||
|
||||
if custom_buttons:
|
||||
for button in custom_buttons:
|
||||
if isinstance(button, InlineKeyboardButton):
|
||||
keyboard.append([button])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
|
||||
@@ -285,6 +291,7 @@ def get_info_menu_keyboard(
|
||||
show_privacy_policy: bool = False,
|
||||
show_public_offer: bool = False,
|
||||
show_faq: bool = False,
|
||||
show_promo_groups: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -298,6 +305,14 @@ def get_info_menu_keyboard(
|
||||
)
|
||||
])
|
||||
|
||||
if show_promo_groups:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_PROMO_GROUPS_INFO", "🎯 Промогруппы"),
|
||||
callback_data="menu_info_promo_groups",
|
||||
)
|
||||
])
|
||||
|
||||
if show_privacy_policy:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -1695,14 +1710,25 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
for days in available_periods:
|
||||
if days in prices:
|
||||
period_display = format_period_description(days, language)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display} - {texts.format_price(prices[days])}",
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
if days not in prices:
|
||||
continue
|
||||
|
||||
price_info = prices[days]
|
||||
|
||||
if isinstance(price_info, dict):
|
||||
final_price = price_info.get("final")
|
||||
if final_price is None:
|
||||
final_price = price_info.get("original", 0)
|
||||
else:
|
||||
final_price = price_info
|
||||
|
||||
period_display = format_period_description(days, language)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display} - {texts.format_price(final_price)}",
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
|
||||
"PAID_FEATURE_ONLY": "⚠ This feature is available only for paid subscriptions",
|
||||
"PAID_FEATURE_ONLY_SHORT": "⚠ Paid subscriptions only",
|
||||
"SUSPICIOUS_DISPLAY_NAME_BLOCKED": "🚫 We detected that your display name looks like a link or an official account. Please change your Telegram name and try again.",
|
||||
"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",
|
||||
@@ -305,6 +306,16 @@
|
||||
"MENU_INFO": "ℹ️ Info",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Info</b>",
|
||||
"MENU_INFO_PROMPT": "Choose a section:",
|
||||
"MENU_PROMO_GROUPS_INFO": "🎯 Promo groups",
|
||||
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Promo groups</b>",
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Spent in the bot: {amount}",
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Current level: {name}",
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Current level: not unlocked yet",
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 Until level “{name}”: spend {amount} more",
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 You already have the highest discount level!",
|
||||
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Auto-assigned levels:",
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — from {amount}",
|
||||
"PROMO_GROUPS_INFO_EMPTY": "Auto-assigned promo groups are not configured yet.",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
@@ -378,6 +389,8 @@
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_SERVER_RANDOM_POOL": "🎲 Random choice among {count} servers",
|
||||
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Trial server",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
|
||||
@@ -757,5 +770,13 @@
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled."
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled.",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Package visibility",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Package enabled.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Package disabled.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "At least one package must remain.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Traffic package visibility",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Active packages: {items}",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "No active packages.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Tap a package to toggle its visibility."
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
|
||||
"PAID_FEATURE_ONLY": "⚠ Эта функция доступна только для платных подписок",
|
||||
"PAID_FEATURE_ONLY_SHORT": "⚠ Только для платных подписок",
|
||||
"SUSPICIOUS_DISPLAY_NAME_BLOCKED": "🚫 Мы обнаружили, что ваше отображаемое имя похоже на ссылку или служебный аккаунт. Пожалуйста, измените имя в профиле Telegram и повторите попытку.",
|
||||
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ Сервер недоступен для вашей промогруппы",
|
||||
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ Изменения не обнаружены",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
|
||||
@@ -234,6 +235,16 @@
|
||||
"MENU_INFO": "ℹ️ Инфо",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Инфо</b>",
|
||||
"MENU_INFO_PROMPT": "Выберите раздел:",
|
||||
"MENU_PROMO_GROUPS_INFO": "🎯 Скидки за траты",
|
||||
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Скидки за траты</b>",
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Потрачено в боте: {amount}",
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Текущий уровень: {name}",
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Текущий уровень: пока не получен",
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 До уровня «{name}»: осталось {amount}",
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 Вы уже получили максимальный уровень скидок!",
|
||||
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Уровни с автовыдачей:",
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — от {amount}",
|
||||
"PROMO_GROUPS_INFO_EMPTY": "Промогруппы с автовыдачей ещё не настроены.",
|
||||
"MENU_PRIVACY_POLICY": "🛡️ Политика конф.",
|
||||
"MENU_PUBLIC_OFFER": "📄 Оферта",
|
||||
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
@@ -793,5 +804,13 @@
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже",
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено"
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Отображение пакетов",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Пакет включен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Пакет отключен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "Должен оставаться хотя бы один пакет.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Отображение пакетов трафика",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Активные пакеты: {items}",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "Активных пакетов нет.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Нажмите на пакет, чтобы включить или выключить его отображение."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Awaitable, Callable, Dict
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
Message,
|
||||
PreCheckoutQuery,
|
||||
TelegramObject,
|
||||
User as TgUser,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ZERO_WIDTH_PATTERN = re.compile(r"[\u200B-\u200D\uFEFF]")
|
||||
|
||||
LINK_PATTERNS = [
|
||||
re.compile(pattern, re.IGNORECASE)
|
||||
for pattern in (
|
||||
r"t\.me/\+",
|
||||
r"joinchat",
|
||||
r"https?://",
|
||||
r"www\.",
|
||||
r"tg://",
|
||||
r"telegram\.me",
|
||||
r"t\.me",
|
||||
)
|
||||
]
|
||||
|
||||
DOMAIN_OBFUSCATION_PATTERN = re.compile(
|
||||
r"(?<![0-9a-zа-яё])(?:t|т)[\s\W_]*?(?:m|м)(?:e|е)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
CHAR_TRANSLATION = str.maketrans({
|
||||
"а": "a",
|
||||
"е": "e",
|
||||
"о": "o",
|
||||
"р": "p",
|
||||
"с": "c",
|
||||
"х": "x",
|
||||
"у": "y",
|
||||
"к": "k",
|
||||
"т": "t",
|
||||
"г": "g",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"л": "l",
|
||||
"і": "i",
|
||||
"ї": "i",
|
||||
"ё": "e",
|
||||
"ь": "",
|
||||
"ъ": "",
|
||||
"ў": "u",
|
||||
"@": "@",
|
||||
})
|
||||
|
||||
COLLAPSE_PATTERN = re.compile(r"[\s\._\-/\\|,:;•·﹒․⋅··`~'\"!?()\[\]{}<>+=]+")
|
||||
|
||||
class DisplayNameRestrictionMiddleware(BaseMiddleware):
|
||||
"""Blocks users whose display name imitates links or official accounts."""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
user: TgUser | None = None
|
||||
|
||||
if isinstance(event, (Message, CallbackQuery, PreCheckoutQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user or user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
display_name = self._build_display_name(user)
|
||||
username = user.username or ""
|
||||
|
||||
display_suspicious = self._is_suspicious(display_name)
|
||||
username_suspicious = self._is_suspicious(username)
|
||||
|
||||
if display_suspicious or username_suspicious:
|
||||
suspicious_value = display_name if display_suspicious else username
|
||||
language = self._resolve_language(user, data)
|
||||
texts = get_texts(language)
|
||||
warning = texts.get(
|
||||
"SUSPICIOUS_DISPLAY_NAME_BLOCKED",
|
||||
"🚫 Ваше отображаемое имя похоже на ссылку или служебный аккаунт. "
|
||||
"Пожалуйста, измените имя и попробуйте снова.",
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"🚫 DisplayNameRestriction: user %s blocked due to suspicious name '%s'",
|
||||
user.id,
|
||||
suspicious_value,
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(warning)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(warning, show_alert=True)
|
||||
elif isinstance(event, PreCheckoutQuery):
|
||||
await event.answer(ok=False, error_message=warning)
|
||||
return None
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
@staticmethod
|
||||
def _build_display_name(user: TgUser) -> str:
|
||||
parts = [user.first_name or "", user.last_name or ""]
|
||||
return " ".join(part for part in parts if part).strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_language(user: TgUser, data: Dict[str, Any]) -> str:
|
||||
db_user = data.get("db_user")
|
||||
if db_user and getattr(db_user, "language", None):
|
||||
return db_user.language
|
||||
language_code = getattr(user, "language_code", None)
|
||||
return language_code or settings.DEFAULT_LANGUAGE
|
||||
|
||||
def _is_suspicious(self, value: str) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
|
||||
cleaned = ZERO_WIDTH_PATTERN.sub("", value)
|
||||
lower_value = cleaned.lower()
|
||||
|
||||
if "@" in cleaned or "@" in cleaned:
|
||||
return True
|
||||
|
||||
if any(pattern.search(lower_value) for pattern in LINK_PATTERNS):
|
||||
return True
|
||||
|
||||
if DOMAIN_OBFUSCATION_PATTERN.search(lower_value):
|
||||
return True
|
||||
|
||||
normalized = self._normalize_text(lower_value)
|
||||
collapsed = COLLAPSE_PATTERN.sub("", normalized)
|
||||
|
||||
if "tme" in collapsed:
|
||||
return True
|
||||
|
||||
banned_keywords = settings.get_display_name_banned_keywords()
|
||||
|
||||
return any(
|
||||
keyword in normalized or keyword in collapsed
|
||||
for keyword in banned_keywords
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_text(value: str) -> str:
|
||||
return value.translate(CHAR_TRANSLATION)
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
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
|
||||
@@ -345,35 +344,22 @@ class AdminNotificationService:
|
||||
|
||||
async def send_balance_topup_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
transaction: Transaction,
|
||||
old_balance: int
|
||||
old_balance: int,
|
||||
*,
|
||||
topup_status: str,
|
||||
referrer_info: str,
|
||||
subscription: Subscription | None,
|
||||
promo_group: PromoGroup | None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
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>
|
||||
|
||||
@@ -125,8 +125,24 @@ class AdvertisingCampaignService:
|
||||
)
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
if not squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
if trial_uuid:
|
||||
squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось подобрать сквад для кампании %s: %s",
|
||||
campaign.id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
@@ -135,6 +151,7 @@ class AdvertisingCampaignService:
|
||||
traffic_limit_gb=traffic_limit or 0,
|
||||
device_limit=device_limit,
|
||||
connected_squads=squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Утилиты для синхронизации токена внешней админки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SystemSetting
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ensure_external_admin_token(
|
||||
bot_username: Optional[str],
|
||||
bot_id: Optional[int],
|
||||
) -> Optional[str]:
|
||||
"""Генерирует и сохраняет токен внешней админки, если требуется."""
|
||||
|
||||
username_raw = (bot_username or "").strip()
|
||||
if not username_raw:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует",
|
||||
)
|
||||
return None
|
||||
|
||||
normalized_username = username_raw.lstrip("@").lower()
|
||||
if not normalized_username:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации",
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
token = settings.build_external_admin_token(normalized_username)
|
||||
except Exception as error: # pragma: no cover - защитный блок
|
||||
logger.error("❌ Ошибка генерации токена внешней админки: %s", error)
|
||||
return None
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SystemSetting.key, SystemSetting.value).where(
|
||||
SystemSetting.key.in_(
|
||||
["EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"]
|
||||
)
|
||||
)
|
||||
)
|
||||
rows = dict(result.all())
|
||||
existing_token = rows.get("EXTERNAL_ADMIN_TOKEN")
|
||||
existing_bot_id_raw = rows.get("EXTERNAL_ADMIN_TOKEN_BOT_ID")
|
||||
|
||||
existing_bot_id: Optional[int] = None
|
||||
if existing_bot_id_raw is not None:
|
||||
try:
|
||||
existing_bot_id = int(existing_bot_id_raw)
|
||||
except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений
|
||||
logger.warning(
|
||||
"⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки: %s",
|
||||
existing_bot_id_raw,
|
||||
)
|
||||
|
||||
if existing_token == token and existing_bot_id == bot_id:
|
||||
if settings.get_external_admin_token() != token:
|
||||
settings.EXTERNAL_ADMIN_TOKEN = token
|
||||
if settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id:
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
|
||||
return token
|
||||
|
||||
if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id:
|
||||
logger.error(
|
||||
"❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен %s, текущий %s",
|
||||
existing_bot_id,
|
||||
bot_id,
|
||||
)
|
||||
|
||||
try:
|
||||
await bot_configuration_service.reset_value(
|
||||
session,
|
||||
"EXTERNAL_ADMIN_TOKEN",
|
||||
force=True,
|
||||
)
|
||||
await bot_configuration_service.reset_value(
|
||||
session,
|
||||
"EXTERNAL_ADMIN_TOKEN_BOT_ID",
|
||||
force=True,
|
||||
)
|
||||
await session.commit()
|
||||
logger.warning(
|
||||
"⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота",
|
||||
)
|
||||
except Exception as cleanup_error: # pragma: no cover - защитный блок
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
"❌ Не удалось очистить токен внешней админки после обнаружения подмены: %s",
|
||||
cleanup_error,
|
||||
)
|
||||
finally:
|
||||
settings.EXTERNAL_ADMIN_TOKEN = None
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None
|
||||
|
||||
return None
|
||||
|
||||
updates: list[tuple[str, object]] = []
|
||||
if existing_token != token:
|
||||
updates.append(("EXTERNAL_ADMIN_TOKEN", token))
|
||||
|
||||
if bot_id is not None and existing_bot_id != bot_id:
|
||||
updates.append(("EXTERNAL_ADMIN_TOKEN_BOT_ID", bot_id))
|
||||
|
||||
if not updates:
|
||||
# Токен совпал, но могли отсутствовать значения в настройках приложения
|
||||
if settings.get_external_admin_token() != (existing_token or token):
|
||||
settings.EXTERNAL_ADMIN_TOKEN = existing_token or token
|
||||
if existing_bot_id is not None and (
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id
|
||||
):
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
|
||||
elif (
|
||||
bot_id is not None
|
||||
and settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != bot_id
|
||||
and existing_bot_id is None
|
||||
):
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id
|
||||
return existing_token or token
|
||||
|
||||
try:
|
||||
for key, value in updates:
|
||||
await bot_configuration_service.set_value(
|
||||
session,
|
||||
key,
|
||||
value,
|
||||
force=True,
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"✅ Токен внешней админки синхронизирован для @%s",
|
||||
normalized_username,
|
||||
)
|
||||
except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение
|
||||
await session.rollback()
|
||||
logger.warning(
|
||||
"⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа",
|
||||
)
|
||||
return None
|
||||
|
||||
return token
|
||||
except SQLAlchemyError as error:
|
||||
logger.error("❌ Ошибка сохранения токена внешней админки: %s", error)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
MainMenuButton,
|
||||
MainMenuButtonActionType,
|
||||
MainMenuButtonVisibility,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _MainMenuButtonData:
|
||||
text: str
|
||||
action_type: MainMenuButtonActionType
|
||||
action_value: str
|
||||
visibility: MainMenuButtonVisibility
|
||||
is_active: bool
|
||||
display_order: int
|
||||
|
||||
|
||||
class MainMenuButtonService:
|
||||
_cache: List[_MainMenuButtonData] | None = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def invalidate_cache(cls) -> None:
|
||||
cls._cache = None
|
||||
|
||||
@classmethod
|
||||
async def _load_cache(cls, db: AsyncSession) -> List[_MainMenuButtonData]:
|
||||
if cls._cache is not None:
|
||||
return cls._cache
|
||||
|
||||
async with cls._lock:
|
||||
if cls._cache is not None:
|
||||
return cls._cache
|
||||
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).order_by(
|
||||
MainMenuButton.display_order.asc(),
|
||||
MainMenuButton.id.asc(),
|
||||
)
|
||||
)
|
||||
|
||||
items: List[_MainMenuButtonData] = []
|
||||
for record in result.scalars().all():
|
||||
text = (record.text or "").strip()
|
||||
action_value = (record.action_value or "").strip()
|
||||
|
||||
if not text or not action_value:
|
||||
continue
|
||||
|
||||
try:
|
||||
action_type = MainMenuButtonActionType(record.action_type)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
try:
|
||||
visibility = MainMenuButtonVisibility(record.visibility)
|
||||
except ValueError:
|
||||
visibility = MainMenuButtonVisibility.ALL
|
||||
|
||||
items.append(
|
||||
_MainMenuButtonData(
|
||||
text=text,
|
||||
action_type=action_type,
|
||||
action_value=action_value,
|
||||
visibility=visibility,
|
||||
is_active=bool(record.is_active),
|
||||
display_order=int(record.display_order or 0),
|
||||
)
|
||||
)
|
||||
|
||||
cls._cache = items
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
async def get_buttons_for_user(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
is_admin: bool,
|
||||
has_active_subscription: bool,
|
||||
subscription_is_active: bool,
|
||||
) -> list[InlineKeyboardButton]:
|
||||
data = await cls._load_cache(db)
|
||||
has_subscription = bool(has_active_subscription and subscription_is_active)
|
||||
|
||||
buttons: list[InlineKeyboardButton] = []
|
||||
for item in data:
|
||||
if not item.is_active:
|
||||
continue
|
||||
|
||||
if item.visibility == MainMenuButtonVisibility.ADMINS and not is_admin:
|
||||
continue
|
||||
|
||||
if item.visibility == MainMenuButtonVisibility.SUBSCRIBERS and not has_subscription:
|
||||
continue
|
||||
|
||||
button = cls._build_button(item)
|
||||
if button:
|
||||
buttons.append(button)
|
||||
|
||||
return buttons
|
||||
|
||||
@staticmethod
|
||||
def _build_button(item: _MainMenuButtonData) -> InlineKeyboardButton | None:
|
||||
if item.action_type == MainMenuButtonActionType.URL:
|
||||
return InlineKeyboardButton(text=item.text, url=item.action_value)
|
||||
|
||||
if item.action_type == MainMenuButtonActionType.MINI_APP:
|
||||
return InlineKeyboardButton(
|
||||
text=item.text,
|
||||
web_app=types.WebAppInfo(url=item.action_value),
|
||||
)
|
||||
|
||||
return None
|
||||
+151
-38
@@ -2,7 +2,7 @@ import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
@@ -24,6 +24,7 @@ from app.external.cryptobot import CryptoBotService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.database.database import get_db
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
@@ -106,16 +107,32 @@ class PaymentService:
|
||||
self,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
payload: Optional[str] = None
|
||||
payload: Optional[str] = None,
|
||||
*,
|
||||
stars_amount: Optional[int] = None,
|
||||
) -> str:
|
||||
|
||||
|
||||
if not self.bot or not self.stars_service:
|
||||
raise ValueError("Bot instance required for Stars payments")
|
||||
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
|
||||
if stars_amount is None:
|
||||
rate = Decimal(str(settings.get_stars_rate()))
|
||||
if rate <= 0:
|
||||
raise ValueError("Stars rate must be positive")
|
||||
|
||||
normalized_stars = (amount_rubles / rate).to_integral_value(
|
||||
rounding=ROUND_FLOOR
|
||||
)
|
||||
stars_amount = int(normalized_stars)
|
||||
if stars_amount <= 0:
|
||||
stars_amount = 1
|
||||
|
||||
if stars_amount <= 0:
|
||||
raise ValueError("Stars amount must be positive")
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title="Пополнение баланса VPN",
|
||||
description=f"{description} (≈{stars_amount} ⭐)",
|
||||
@@ -125,7 +142,11 @@ class PaymentService:
|
||||
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
|
||||
)
|
||||
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽)")
|
||||
logger.info(
|
||||
"Создан Stars invoice на %s звезд (~%s)",
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
@@ -142,7 +163,7 @@ class PaymentService:
|
||||
) -> bool:
|
||||
try:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
|
||||
amount_kopeks = int(rubles_amount * 100)
|
||||
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
@@ -158,16 +179,21 @@ class PaymentService:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
|
||||
description_for_referral = f"Пополнение Stars: {int(rubles_amount)}₽ ({stars_amount} ⭐)"
|
||||
|
||||
description_for_referral = (
|
||||
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
|
||||
)
|
||||
logger.info(f"🔍 Проверка реферальной логики для описания: '{description_for_referral}'")
|
||||
|
||||
if any(word in description_for_referral.lower() for word in ["пополнение", "stars", "yookassa", "topup"]) and not any(word in description_for_referral.lower() for word in ["комиссия", "бонус"]):
|
||||
@@ -179,13 +205,27 @@ class PaymentService:
|
||||
logger.error(f"Ошибка обработки реферального пополнения: {e}")
|
||||
else:
|
||||
logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики")
|
||||
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
|
||||
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
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
@@ -206,14 +246,18 @@ class PaymentService:
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {int(rubles_amount)}₽"
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
||||
user.telegram_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
|
||||
logger.info(
|
||||
f"✅ Обработан Stars платеж: пользователь {user_id}, "
|
||||
f"{stars_amount} звезд → {int(rubles_amount)}₽"
|
||||
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
|
||||
user_id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
else:
|
||||
@@ -449,25 +493,42 @@ class PaymentService:
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += updated_payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, updated_payment.amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения YooKassa: {e}")
|
||||
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
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
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении YooKassa: {e}")
|
||||
@@ -818,6 +879,7 @@ class PaymentService:
|
||||
language: str,
|
||||
ttl_seconds: Optional[int] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.pal24_service or not self.pal24_service.is_configured:
|
||||
@@ -848,6 +910,8 @@ class PaymentService:
|
||||
"language": language,
|
||||
}
|
||||
|
||||
normalized_payment_method = (payment_method or "SBP").upper()
|
||||
|
||||
try:
|
||||
response = await self.pal24_service.create_bill(
|
||||
amount_kopeks=amount_kopeks,
|
||||
@@ -857,7 +921,7 @@ class PaymentService:
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
payment_method="SBP",
|
||||
payment_method=normalized_payment_method,
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
@@ -952,6 +1016,7 @@ class PaymentService:
|
||||
"sbp_url": transfer_url or primary_link,
|
||||
"card_url": card_url,
|
||||
"transfer_url": transfer_url,
|
||||
"payment_method": normalized_payment_method,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -1045,11 +1110,17 @@ class PaymentService:
|
||||
return False
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
@@ -1061,6 +1132,12 @@ class PaymentService:
|
||||
referral_error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
@@ -1077,10 +1154,13 @@ class PaymentService:
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
@@ -1238,10 +1318,17 @@ class PaymentService:
|
||||
await link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
@@ -1250,16 +1337,25 @@ class PaymentService:
|
||||
except Exception as referral_error:
|
||||
logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
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,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error("Ошибка отправки админ уведомления Pal24: %s", notify_error)
|
||||
@@ -1494,25 +1590,42 @@ class PaymentService:
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения CryptoBot: {e}")
|
||||
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
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
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении CryptoBot: {e}")
|
||||
|
||||
@@ -113,16 +113,32 @@ class PromoCodeService:
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
|
||||
trial_squads = []
|
||||
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось подобрать сквад для подписки по промокоду %s: %s",
|
||||
promocode.code,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=promocode.subscription_days,
|
||||
traffic_limit_gb=0,
|
||||
traffic_limit_gb=0,
|
||||
device_limit=1,
|
||||
connected_squads=trial_squads
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, new_subscription)
|
||||
@@ -140,9 +156,9 @@ class PromoCodeService:
|
||||
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
|
||||
|
||||
trial_subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict, List, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import re
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
|
||||
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
|
||||
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from sqlalchemy import and_, cast, delete, func, select, update, String
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, update_subscription_usage
|
||||
from app.database.crud.subscription import (
|
||||
get_subscription_by_user_id,
|
||||
update_subscription_usage,
|
||||
decrement_subscription_server_counts,
|
||||
)
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.models import (
|
||||
User, SubscriptionServer, Transaction, ReferralEarning,
|
||||
PromoCodeUse, SubscriptionStatus
|
||||
User,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
Transaction,
|
||||
ReferralEarning,
|
||||
PromoCodeUse,
|
||||
SubscriptionStatus,
|
||||
ServerSquad,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,6 +49,16 @@ class RemnaWaveService:
|
||||
|
||||
self._config_error: Optional[str] = None
|
||||
|
||||
tz_name = os.getenv("TZ", "UTC")
|
||||
try:
|
||||
self._panel_timezone = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s'. Используется UTC.",
|
||||
tz_name,
|
||||
)
|
||||
self._panel_timezone = ZoneInfo("UTC")
|
||||
|
||||
if not base_url:
|
||||
self._config_error = "REMNAWAVE_API_URL не настроен"
|
||||
elif not api_key:
|
||||
@@ -72,33 +97,41 @@ class RemnaWaveService:
|
||||
async with self.api as api:
|
||||
yield api
|
||||
|
||||
def _now_in_panel_timezone(self) -> datetime:
|
||||
"""Возвращает текущее время без часового пояса в зоне панели."""
|
||||
return datetime.now(self._panel_timezone).replace(tzinfo=None)
|
||||
|
||||
def _parse_remnawave_date(self, date_str: str) -> datetime:
|
||||
if not date_str:
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
return self._now_in_panel_timezone() + timedelta(days=30)
|
||||
|
||||
try:
|
||||
|
||||
|
||||
cleaned_date = date_str.strip()
|
||||
|
||||
|
||||
if cleaned_date.endswith('Z'):
|
||||
cleaned_date = cleaned_date[:-1] + '+00:00'
|
||||
|
||||
|
||||
if '+00:00+00:00' in cleaned_date:
|
||||
cleaned_date = cleaned_date.replace('+00:00+00:00', '+00:00')
|
||||
|
||||
|
||||
cleaned_date = re.sub(r'(\+\d{2}:\d{2})\+\d{2}:\d{2}$', r'\1', cleaned_date)
|
||||
|
||||
|
||||
parsed_date = datetime.fromisoformat(cleaned_date)
|
||||
|
||||
|
||||
if parsed_date.tzinfo is not None:
|
||||
parsed_date = parsed_date.replace(tzinfo=None)
|
||||
|
||||
logger.debug(f"Успешно распарсена дата: {date_str} -> {parsed_date}")
|
||||
return parsed_date
|
||||
|
||||
localized = parsed_date.astimezone(self._panel_timezone)
|
||||
else:
|
||||
localized = parsed_date.replace(tzinfo=self._panel_timezone)
|
||||
|
||||
localized_naive = localized.replace(tzinfo=None)
|
||||
|
||||
logger.debug(f"Успешно распарсена дата: {date_str} -> {localized_naive}")
|
||||
return localized_naive
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.")
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
return self._now_in_panel_timezone() + timedelta(days=30)
|
||||
|
||||
async def get_system_statistics(self) -> Dict[str, Any]:
|
||||
try:
|
||||
@@ -456,16 +489,237 @@ class RemnaWaveService:
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
result = await api.delete_internal_squad(uuid)
|
||||
|
||||
|
||||
if result:
|
||||
logger.info(f"✅ Удален сквад {uuid}")
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления сквада {uuid}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_squad_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
source_uuid: str,
|
||||
target_uuid: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Переносит активных подписок с одного сквада на другой."""
|
||||
|
||||
if source_uuid == target_uuid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "same_squad",
|
||||
"message": "Источник и назначение совпадают",
|
||||
}
|
||||
|
||||
source_uuid = source_uuid.strip()
|
||||
target_uuid = target_uuid.strip()
|
||||
|
||||
source_server = await get_server_squad_by_uuid(db, source_uuid)
|
||||
target_server = await get_server_squad_by_uuid(db, target_uuid)
|
||||
|
||||
if not source_server or not target_server:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "not_found",
|
||||
"message": "Сквады не найдены",
|
||||
}
|
||||
|
||||
subscription_query = (
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
]
|
||||
),
|
||||
cast(Subscription.connected_squads, String).like(
|
||||
f'%"{source_uuid}"%'
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(subscription_query)
|
||||
subscriptions = result.scalars().unique().all()
|
||||
|
||||
total_candidates = len(subscriptions)
|
||||
if not subscriptions:
|
||||
logger.info(
|
||||
"🚚 Переезд сквада %s → %s: подходящих подписок не найдено",
|
||||
source_uuid,
|
||||
target_uuid,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"total": 0,
|
||||
"updated": 0,
|
||||
"panel_updated": 0,
|
||||
"panel_failed": 0,
|
||||
}
|
||||
|
||||
exit_stack = AsyncExitStack()
|
||||
panel_updated = 0
|
||||
panel_failed = 0
|
||||
updated_subscriptions = 0
|
||||
source_decrement = 0
|
||||
target_increment = 0
|
||||
|
||||
try:
|
||||
needs_panel_update = any(
|
||||
subscription.user and subscription.user.remnawave_uuid
|
||||
for subscription in subscriptions
|
||||
)
|
||||
|
||||
api = None
|
||||
if needs_panel_update:
|
||||
api = await exit_stack.enter_async_context(self.get_api_client())
|
||||
|
||||
for subscription in subscriptions:
|
||||
current_squads = list(subscription.connected_squads or [])
|
||||
if source_uuid not in current_squads:
|
||||
continue
|
||||
|
||||
had_target_before = target_uuid in current_squads
|
||||
new_squads = [
|
||||
squad_uuid for squad_uuid in current_squads if squad_uuid != source_uuid
|
||||
]
|
||||
if not had_target_before:
|
||||
new_squads.append(target_uuid)
|
||||
|
||||
if subscription.user and subscription.user.remnawave_uuid:
|
||||
if api is None:
|
||||
panel_failed += 1
|
||||
logger.error(
|
||||
"❌ RemnaWave API недоступен для обновления пользователя %s",
|
||||
subscription.user.telegram_id,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=subscription.user.remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
)
|
||||
panel_updated += 1
|
||||
except Exception as error:
|
||||
panel_failed += 1
|
||||
logger.error(
|
||||
"❌ Ошибка обновления сквадов пользователя %s: %s",
|
||||
subscription.user.telegram_id,
|
||||
error,
|
||||
)
|
||||
continue
|
||||
|
||||
subscription.connected_squads = new_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
source_decrement += 1
|
||||
if not had_target_before:
|
||||
target_increment += 1
|
||||
|
||||
updated_subscriptions += 1
|
||||
|
||||
link_result = await db.execute(
|
||||
select(SubscriptionServer)
|
||||
.where(
|
||||
and_(
|
||||
SubscriptionServer.subscription_id == subscription.id,
|
||||
SubscriptionServer.server_squad_id == source_server.id,
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
link = link_result.scalars().first()
|
||||
|
||||
if link:
|
||||
if had_target_before:
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
and_(
|
||||
SubscriptionServer.subscription_id
|
||||
== subscription.id,
|
||||
SubscriptionServer.server_squad_id
|
||||
== source_server.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
link.server_squad_id = target_server.id
|
||||
elif not had_target_before:
|
||||
db.add(
|
||||
SubscriptionServer(
|
||||
subscription_id=subscription.id,
|
||||
server_squad_id=target_server.id,
|
||||
paid_price_kopeks=0,
|
||||
)
|
||||
)
|
||||
|
||||
if updated_subscriptions:
|
||||
if source_decrement:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == source_server.id)
|
||||
.values(
|
||||
current_users=func.greatest(
|
||||
ServerSquad.current_users - source_decrement,
|
||||
0,
|
||||
)
|
||||
)
|
||||
)
|
||||
if target_increment:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == target_server.id)
|
||||
.values(
|
||||
current_users=ServerSquad.current_users + target_increment
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
else:
|
||||
await db.rollback()
|
||||
|
||||
logger.info(
|
||||
"🚚 Завершен переезд сквада %s → %s: обновлено %s подписок (%s не обновлены в панели)",
|
||||
source_uuid,
|
||||
target_uuid,
|
||||
updated_subscriptions,
|
||||
panel_failed,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total": total_candidates,
|
||||
"updated": updated_subscriptions,
|
||||
"panel_updated": panel_updated,
|
||||
"panel_failed": panel_failed,
|
||||
"source_removed": source_decrement,
|
||||
"target_added": target_increment,
|
||||
}
|
||||
|
||||
except RemnaWaveConfigurationError:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as error:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"❌ Ошибка переезда сквада %s → %s: %s",
|
||||
source_uuid,
|
||||
target_uuid,
|
||||
error,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": "unexpected",
|
||||
"message": str(error),
|
||||
}
|
||||
finally:
|
||||
await exit_stack.aclose()
|
||||
|
||||
async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]:
|
||||
try:
|
||||
stats = {"created": 0, "updated": 0, "errors": 0, "deleted": 0}
|
||||
@@ -600,7 +854,9 @@ class RemnaWaveService:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from app.database.models import SubscriptionServer
|
||||
|
||||
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == subscription.id
|
||||
@@ -652,7 +908,7 @@ class RemnaWaveService:
|
||||
expire_at = self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
panel_status = panel_user.get('status', 'ACTIVE')
|
||||
current_time = datetime.utcnow()
|
||||
current_time = self._now_in_panel_timezone()
|
||||
|
||||
if panel_status == 'ACTIVE' and expire_at > current_time:
|
||||
status = SubscriptionStatus.ACTIVE
|
||||
@@ -707,7 +963,7 @@ class RemnaWaveService:
|
||||
user_id=user.id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=False,
|
||||
end_date=datetime.utcnow() + timedelta(days=30),
|
||||
end_date=self._now_in_panel_timezone() + timedelta(days=30),
|
||||
traffic_limit_gb=0,
|
||||
traffic_used_gb=0.0,
|
||||
device_limit=1,
|
||||
@@ -744,7 +1000,7 @@ class RemnaWaveService:
|
||||
subscription.end_date = expire_at
|
||||
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
current_time = self._now_in_panel_timezone()
|
||||
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
|
||||
new_status = SubscriptionStatus.ACTIVE.value
|
||||
elif subscription.end_date <= current_time:
|
||||
@@ -1116,6 +1372,8 @@ class RemnaWaveService:
|
||||
)
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
@@ -1150,22 +1408,22 @@ class RemnaWaveService:
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.used_promocodes = 0
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = self._now_in_panel_timezone()
|
||||
|
||||
if user.subscription:
|
||||
user.subscription.status = SubscriptionStatus.DISABLED.value
|
||||
user.subscription.is_trial = True
|
||||
user.subscription.end_date = datetime.utcnow()
|
||||
user.subscription.end_date = self._now_in_panel_timezone()
|
||||
user.subscription.traffic_limit_gb = 0
|
||||
user.subscription.traffic_used_gb = 0.0
|
||||
user.subscription.device_limit = 1
|
||||
user.subscription.connected_squads = []
|
||||
user.subscription.autopay_enabled = False
|
||||
user.subscription.autopay_days_before = 3
|
||||
user.subscription.autopay_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
user.subscription.remnawave_short_uuid = None
|
||||
user.subscription.subscription_url = ""
|
||||
user.subscription.subscription_crypto_link = ""
|
||||
user.subscription.updated_at = datetime.utcnow()
|
||||
user.subscription.updated_at = self._now_in_panel_timezone()
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1334,7 +1592,7 @@ class RemnaWaveService:
|
||||
user = subscription.user
|
||||
issues_fixed = 0
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
current_time = self._now_in_panel_timezone()
|
||||
if subscription.end_date <= current_time and subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
logger.info(f"🔧 Исправляем статус просроченной подписки {user.telegram_id}")
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,9 +56,16 @@ class ChoiceOption:
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ReadOnlySettingError(RuntimeError):
|
||||
"""Исключение, выбрасываемое при попытке изменить настройку только для чтения."""
|
||||
|
||||
|
||||
class BotConfigurationService:
|
||||
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
|
||||
|
||||
READ_ONLY_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"}
|
||||
PLAIN_TEXT_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"}
|
||||
|
||||
CATEGORY_TITLES: Dict[str, str] = {
|
||||
"CORE": "🤖 Основные настройки",
|
||||
"SUPPORT": "💬 Поддержка и тикеты",
|
||||
@@ -71,6 +78,7 @@ class BotConfigurationService:
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 MulenPay",
|
||||
"PAL24": "🏦 PAL24 / PayPalych",
|
||||
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
|
||||
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
|
||||
"PERIODS": "📆 Периоды подписок",
|
||||
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
|
||||
@@ -104,6 +112,7 @@ class BotConfigurationService:
|
||||
"WEBHOOK": "🌐 Webhook",
|
||||
"LOG": "📝 Логирование",
|
||||
"DEBUG": "🧪 Режим разработки",
|
||||
"MODERATION": "🛡️ Модерация и фильтры",
|
||||
}
|
||||
|
||||
CATEGORY_DESCRIPTIONS: Dict[str, str] = {
|
||||
@@ -118,6 +127,7 @@ class BotConfigurationService:
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
"TELEGRAM": "Telegram Stars и их стоимость.",
|
||||
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
|
||||
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
|
||||
"PERIODS": "Доступные периоды подписок и продлений.",
|
||||
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
|
||||
@@ -151,6 +161,7 @@ class BotConfigurationService:
|
||||
"WEBHOOK": "Пути и секреты вебхуков.",
|
||||
"LOG": "Уровни логирования и ротация.",
|
||||
"DEBUG": "Отладочные функции и безопасный режим.",
|
||||
"MODERATION": "Настройки фильтров отображаемых имен и защиты от фишинга.",
|
||||
}
|
||||
|
||||
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
|
||||
@@ -184,6 +195,7 @@ class BotConfigurationService:
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
|
||||
"REFERRED_USER_REWARD": "REFERRAL",
|
||||
"DEFAULT_AUTOPAY_ENABLED": "AUTOPAY",
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
"TRIAL_WARNING_HOURS": "TRIAL",
|
||||
@@ -255,6 +267,7 @@ class BotConfigurationService:
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
|
||||
"CONNECT_BUTTON_HAPP": "HAPP",
|
||||
"HAPP_": "HAPP",
|
||||
"SKIP_": "SKIP",
|
||||
@@ -269,6 +282,7 @@ class BotConfigurationService:
|
||||
"LOG_": "LOG",
|
||||
"WEB_API_": "WEB_API",
|
||||
"DEBUG": "DEBUG",
|
||||
"DISPLAY_NAME_": "MODERATION",
|
||||
}
|
||||
|
||||
CHOICES: Dict[str, List[ChoiceOption]] = {
|
||||
@@ -387,6 +401,13 @@ class BotConfigurationService:
|
||||
"warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.",
|
||||
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"DISPLAY_NAME_BANNED_KEYWORDS": {
|
||||
"description": "Список слов и фрагментов, при наличии которых в отображаемом имени пользователь будет заблокирован.",
|
||||
"format": "Перечислите ключевые слова через запятую или с новой строки.",
|
||||
"example": "support, security, служебн",
|
||||
"warning": "Слишком агрессивные фильтры могут блокировать добросовестных пользователей.",
|
||||
"dependencies": "Фильтр отображаемых имен",
|
||||
},
|
||||
"REMNAWAVE_API_URL": {
|
||||
"description": "Базовый адрес панели RemnaWave, с которой синхронизируется бот.",
|
||||
"format": "Полный URL вида https://panel.example.com.",
|
||||
@@ -394,6 +415,20 @@ class BotConfigurationService:
|
||||
"warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.",
|
||||
"dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD",
|
||||
},
|
||||
"EXTERNAL_ADMIN_TOKEN": {
|
||||
"description": "Приватный токен, который использует внешняя админка для проверки запросов.",
|
||||
"format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.",
|
||||
"example": "Генерируется автоматически",
|
||||
"warning": "Токен обновится при смене username или токена бота.",
|
||||
"dependencies": "Username телеграм-бота, токен бота",
|
||||
},
|
||||
"EXTERNAL_ADMIN_TOKEN_BOT_ID": {
|
||||
"description": "Идентификатор телеграм-бота, с которым связан токен внешней админки.",
|
||||
"format": "Проставляется автоматически после первого запуска и не редактируется вручную.",
|
||||
"example": "123456789",
|
||||
"warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.",
|
||||
"dependencies": "Результат вызова getMe() в Telegram Bot API",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -405,6 +440,10 @@ class BotConfigurationService:
|
||||
definition = cls.get_definition(key)
|
||||
return definition.python_type is bool
|
||||
|
||||
@classmethod
|
||||
def is_read_only(cls, key: str) -> bool:
|
||||
return key in cls.READ_ONLY_KEYS
|
||||
|
||||
@classmethod
|
||||
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
|
||||
if isinstance(value, bool):
|
||||
@@ -455,6 +494,8 @@ class BotConfigurationService:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
return "—"
|
||||
if key in cls.PLAIN_TEXT_KEYS:
|
||||
return cleaned
|
||||
if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")):
|
||||
return "••••••••"
|
||||
items = cls._split_comma_values(cleaned)
|
||||
@@ -860,7 +901,17 @@ class BotConfigurationService:
|
||||
return parsed_value
|
||||
|
||||
@classmethod
|
||||
async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None:
|
||||
async def set_value(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
value: Any,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if cls.is_read_only(key) and not force:
|
||||
raise ReadOnlySettingError(f"Setting {key} is read-only")
|
||||
|
||||
raw_value = cls.serialize_value(key, value)
|
||||
await upsert_system_setting(db, key, raw_value)
|
||||
cls._overrides_raw[key] = raw_value
|
||||
@@ -870,7 +921,16 @@ class BotConfigurationService:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
async def reset_value(cls, db: AsyncSession, key: str) -> None:
|
||||
async def reset_value(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if cls.is_read_only(key) and not force:
|
||||
raise ReadOnlySettingError(f"Setting {key} is read-only")
|
||||
|
||||
await delete_system_setting(db, key)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
original = cls.get_original_value(key)
|
||||
@@ -925,6 +985,7 @@ class BotConfigurationService:
|
||||
"category_key": definition.category_key,
|
||||
"category_label": definition.category_label,
|
||||
"has_override": has_override,
|
||||
"is_read_only": cls.is_read_only(key),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ from app.database.models import Transaction, TransactionType, PaymentMethod
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction, get_transaction_by_external_id, complete_transaction
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -120,32 +121,52 @@ class TributeService:
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Tribute: {amount_kopeks/100}₽ (ID: {payment_id})"
|
||||
)
|
||||
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
|
||||
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
|
||||
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(session, user.id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения Tribute: {e}")
|
||||
|
||||
if not user.has_made_first_topup:
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await session.commit()
|
||||
|
||||
await session.refresh(user)
|
||||
|
||||
logger.info(
|
||||
f"✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})"
|
||||
)
|
||||
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
|
||||
|
||||
if was_first_topup:
|
||||
logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}")
|
||||
|
||||
|
||||
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
session, user, transaction, old_balance
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
||||
|
||||
@@ -13,7 +13,10 @@ from app.database.crud.user import (
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import (
|
||||
get_subscription_by_user_id,
|
||||
decrement_subscription_server_counts,
|
||||
)
|
||||
from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
@@ -534,7 +537,13 @@ class UserService:
|
||||
)
|
||||
)
|
||||
subscription_servers = subscription_servers_result.scalars().all()
|
||||
|
||||
|
||||
await decrement_subscription_server_counts(
|
||||
db,
|
||||
user.subscription,
|
||||
subscription_servers=subscription_servers,
|
||||
)
|
||||
|
||||
if subscription_servers:
|
||||
logger.info(f"🔄 Удаляем {len(subscription_servers)} связей подписка-сервер")
|
||||
await db.execute(
|
||||
|
||||
@@ -166,6 +166,13 @@ class SquadCreateStates(StatesGroup):
|
||||
class SquadRenameStates(StatesGroup):
|
||||
waiting_for_new_name = State()
|
||||
|
||||
|
||||
class SquadMigrationStates(StatesGroup):
|
||||
selecting_source = State()
|
||||
selecting_target = State()
|
||||
confirming = State()
|
||||
|
||||
|
||||
class AdminSubmenuStates(StatesGroup):
|
||||
in_users_submenu = State()
|
||||
in_promo_submenu = State()
|
||||
|
||||
@@ -48,18 +48,36 @@ async def update_or_create_subscription(
|
||||
for key, value in subscription_data.items():
|
||||
if hasattr(existing_subscription, key):
|
||||
setattr(existing_subscription, key, value)
|
||||
|
||||
|
||||
existing_subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
|
||||
logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}")
|
||||
return existing_subscription
|
||||
|
||||
|
||||
else:
|
||||
subscription_defaults = dict(subscription_data)
|
||||
autopay_enabled = subscription_defaults.pop(
|
||||
"autopay_enabled", None
|
||||
)
|
||||
autopay_days_before = subscription_defaults.pop(
|
||||
"autopay_days_before", None
|
||||
)
|
||||
|
||||
new_subscription = Subscription(
|
||||
user_id=user_id,
|
||||
**subscription_data
|
||||
autopay_enabled=(
|
||||
settings.is_autopay_enabled_by_default()
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
),
|
||||
autopay_days_before=(
|
||||
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
),
|
||||
**subscription_defaults
|
||||
)
|
||||
|
||||
db.add(new_subscription)
|
||||
|
||||
@@ -12,6 +12,25 @@ from app.database.models import User, ReferralEarning, Transaction, TransactionT
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_referrer_info(user: User) -> str:
|
||||
"""Return formatted referrer info for admin notifications."""
|
||||
|
||||
referred_by_id = getattr(user, "referred_by_id", None)
|
||||
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
referrer = getattr(user, "referrer", None)
|
||||
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
|
||||
return f"ID {referrer.telegram_id}"
|
||||
|
||||
|
||||
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
|
||||
max_attempts = 10
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from .routes import (
|
||||
campaigns,
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
promocodes,
|
||||
miniapp,
|
||||
promo_groups,
|
||||
@@ -40,6 +41,10 @@ OPENAPI_TAGS = [
|
||||
"name": "settings",
|
||||
"description": "Получение и изменение конфигурации бота из административной панели.",
|
||||
},
|
||||
{
|
||||
"name": "main-menu",
|
||||
"description": "Управление кнопками главного меню Telegram-бота.",
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
"description": "Управление пользователями, балансом и статусами подписок.",
|
||||
@@ -120,6 +125,11 @@ def create_web_api_app() -> FastAPI:
|
||||
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
|
||||
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
|
||||
app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
|
||||
app.include_router(
|
||||
main_menu_buttons.router,
|
||||
prefix="/main-menu/buttons",
|
||||
tags=["main-menu"],
|
||||
)
|
||||
app.include_router(pages.router, prefix="/pages", tags=["pages"])
|
||||
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
|
||||
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from . import (
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
miniapp,
|
||||
promo_offers,
|
||||
pages,
|
||||
@@ -17,6 +18,7 @@ from . import (
|
||||
__all__ = [
|
||||
"config",
|
||||
"health",
|
||||
"main_menu_buttons",
|
||||
"miniapp",
|
||||
"promo_offers",
|
||||
"pages",
|
||||
|
||||
@@ -5,7 +5,10 @@ from typing import Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.config import (
|
||||
@@ -94,6 +97,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
|
||||
current=current,
|
||||
original=original,
|
||||
has_override=has_override,
|
||||
read_only=bot_configuration_service.is_read_only(definition.key),
|
||||
choices=choices,
|
||||
)
|
||||
|
||||
@@ -153,7 +157,10 @@ async def update_setting(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
value = _coerce_value(key, payload.value)
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError as error:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
return _serialize_definition(definition)
|
||||
@@ -170,6 +177,9 @@ async def reset_setting(
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
try:
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
except ReadOnlySettingError as error:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
return _serialize_definition(definition)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.main_menu_button import (
|
||||
count_main_menu_buttons,
|
||||
create_main_menu_button,
|
||||
delete_main_menu_button,
|
||||
get_main_menu_button_by_id,
|
||||
get_main_menu_buttons,
|
||||
update_main_menu_button,
|
||||
)
|
||||
from app.database.models import MainMenuButton
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.main_menu_buttons import (
|
||||
MainMenuButtonCreateRequest,
|
||||
MainMenuButtonListResponse,
|
||||
MainMenuButtonResponse,
|
||||
MainMenuButtonUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(button: MainMenuButton) -> MainMenuButtonResponse:
|
||||
return MainMenuButtonResponse(
|
||||
id=button.id,
|
||||
text=button.text,
|
||||
action_type=button.action_type_enum,
|
||||
action_value=button.action_value,
|
||||
visibility=button.visibility_enum,
|
||||
is_active=button.is_active,
|
||||
display_order=button.display_order,
|
||||
created_at=button.created_at,
|
||||
updated_at=button.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=MainMenuButtonListResponse)
|
||||
async def list_main_menu_buttons(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> MainMenuButtonListResponse:
|
||||
total = await count_main_menu_buttons(db)
|
||||
buttons = await get_main_menu_buttons(db, limit=limit, offset=offset)
|
||||
|
||||
return MainMenuButtonListResponse(
|
||||
items=[_serialize(button) for button in buttons],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=MainMenuButtonResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_main_menu_button_endpoint(
|
||||
payload: MainMenuButtonCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MainMenuButtonResponse:
|
||||
button = await create_main_menu_button(
|
||||
db,
|
||||
text=payload.text,
|
||||
action_type=payload.action_type,
|
||||
action_value=payload.action_value,
|
||||
visibility=payload.visibility,
|
||||
is_active=payload.is_active,
|
||||
display_order=payload.display_order,
|
||||
)
|
||||
|
||||
MainMenuButtonService.invalidate_cache()
|
||||
return _serialize(button)
|
||||
|
||||
|
||||
@router.patch("/{button_id}", response_model=MainMenuButtonResponse)
|
||||
async def update_main_menu_button_endpoint(
|
||||
button_id: int,
|
||||
payload: MainMenuButtonUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MainMenuButtonResponse:
|
||||
button = await get_main_menu_button_by_id(db, button_id)
|
||||
if not button:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found")
|
||||
|
||||
update_payload = payload.dict(exclude_unset=True)
|
||||
button = await update_main_menu_button(db, button, **update_payload)
|
||||
|
||||
MainMenuButtonService.invalidate_cache()
|
||||
return _serialize(button)
|
||||
|
||||
|
||||
@router.delete("/{button_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_main_menu_button_endpoint(
|
||||
button_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
button = await get_main_menu_button_by_id(db, button_id)
|
||||
if not button:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found")
|
||||
|
||||
await delete_main_menu_button(db, button)
|
||||
MainMenuButtonService.invalidate_cache()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
+4424
-47
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,11 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.server_squad import (
|
||||
count_active_users_for_squad,
|
||||
get_server_squad_by_uuid,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.remnawave import (
|
||||
RemnaWaveConnectionStatus,
|
||||
@@ -22,6 +27,10 @@ from ..schemas.remnawave import (
|
||||
RemnaWaveSquadActionRequest,
|
||||
RemnaWaveSquadCreateRequest,
|
||||
RemnaWaveSquadListResponse,
|
||||
RemnaWaveSquadMigrationPreviewResponse,
|
||||
RemnaWaveSquadMigrationRequest,
|
||||
RemnaWaveSquadMigrationResponse,
|
||||
RemnaWaveSquadMigrationStats,
|
||||
RemnaWaveSquadUpdateRequest,
|
||||
RemnaWaveStatusResponse,
|
||||
RemnaWaveSystemStatsResponse,
|
||||
@@ -372,6 +381,30 @@ async def get_user_traffic(
|
||||
return RemnaWaveUserTrafficResponse(telegram_id=telegram_id, **stats)
|
||||
|
||||
|
||||
@router.get("/squads/{squad_uuid}/migration-preview", response_model=RemnaWaveSquadMigrationPreviewResponse)
|
||||
async def preview_squad_migration(
|
||||
squad_uuid: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveSquadMigrationPreviewResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
squad = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if not squad:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден")
|
||||
|
||||
users_to_migrate = await count_active_users_for_squad(db, squad_uuid)
|
||||
|
||||
return RemnaWaveSquadMigrationPreviewResponse(
|
||||
squad_uuid=squad.squad_uuid,
|
||||
squad_name=squad.display_name,
|
||||
current_users=squad.current_users or 0,
|
||||
max_users=squad.max_users,
|
||||
users_to_migrate=users_to_migrate,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync/from-panel", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def sync_from_panel(
|
||||
payload: RemnaWaveSyncFromPanelRequest,
|
||||
@@ -454,3 +487,58 @@ async def get_sync_recommendations(
|
||||
data = await service.get_sync_recommendations(db)
|
||||
detail = "Рекомендации получены"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=data)
|
||||
|
||||
|
||||
@router.post("/squads/migrate", response_model=RemnaWaveSquadMigrationResponse)
|
||||
async def migrate_squad(
|
||||
payload: RemnaWaveSquadMigrationRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveSquadMigrationResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
source_uuid = payload.source_uuid.strip()
|
||||
target_uuid = payload.target_uuid.strip()
|
||||
|
||||
if source_uuid == target_uuid:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Источник и назначение совпадают")
|
||||
|
||||
source = await get_server_squad_by_uuid(db, source_uuid)
|
||||
if not source:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-источник не найден")
|
||||
|
||||
target = await get_server_squad_by_uuid(db, target_uuid)
|
||||
if not target:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-назначение не найден")
|
||||
|
||||
try:
|
||||
result = await service.migrate_squad_users(
|
||||
db,
|
||||
source_uuid=source.squad_uuid,
|
||||
target_uuid=target.squad_uuid,
|
||||
)
|
||||
except RemnaWaveConfigurationError as exc: # pragma: no cover - зависит от окружения
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
||||
|
||||
if not result.get("success"):
|
||||
detail = result.get("message") or "Не удалось выполнить переезд"
|
||||
return RemnaWaveSquadMigrationResponse(
|
||||
success=False,
|
||||
detail=detail,
|
||||
error=result.get("error"),
|
||||
)
|
||||
|
||||
stats = RemnaWaveSquadMigrationStats(
|
||||
source_uuid=source.squad_uuid,
|
||||
target_uuid=target.squad_uuid,
|
||||
total=result.get("total", 0),
|
||||
updated=result.get("updated", 0),
|
||||
panel_updated=result.get("panel_updated", 0),
|
||||
panel_failed=result.get("panel_failed", 0),
|
||||
source_removed=result.get("source_removed", 0),
|
||||
target_added=result.get("target_added", 0),
|
||||
)
|
||||
|
||||
detail = result.get("message") or "Переезд выполнен"
|
||||
return RemnaWaveSquadMigrationResponse(success=True, detail=detail, data=stats)
|
||||
|
||||
@@ -131,6 +131,7 @@ async def create_subscription(
|
||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
connected_squads=payload.connected_squads or [],
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
|
||||
@@ -45,6 +45,7 @@ class SettingDefinition(BaseModel):
|
||||
current: Any | None = Field(default=None)
|
||||
original: Any | None = Field(default=None)
|
||||
has_override: bool
|
||||
read_only: bool = Field(default=False)
|
||||
choices: list[SettingChoice] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from app.database.models import MainMenuButtonActionType, MainMenuButtonVisibility
|
||||
|
||||
|
||||
def _clean_text(value: str) -> str:
|
||||
cleaned = (value or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Text cannot be empty")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _validate_action_value(value: str) -> str:
|
||||
cleaned = (value or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Action value cannot be empty")
|
||||
if not cleaned.lower().startswith(("http://", "https://")):
|
||||
raise ValueError("Action value must start with http:// or https://")
|
||||
return cleaned
|
||||
|
||||
|
||||
class MainMenuButtonResponse(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
action_type: MainMenuButtonActionType
|
||||
action_value: str
|
||||
visibility: MainMenuButtonVisibility
|
||||
is_active: bool
|
||||
display_order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class MainMenuButtonCreateRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=64)
|
||||
action_type: MainMenuButtonActionType
|
||||
action_value: str = Field(..., min_length=1, max_length=1024)
|
||||
visibility: MainMenuButtonVisibility = MainMenuButtonVisibility.ALL
|
||||
is_active: bool = True
|
||||
display_order: Optional[int] = Field(None, ge=0)
|
||||
|
||||
_normalize_text = validator("text", allow_reuse=True)(_clean_text)
|
||||
_normalize_action_value = validator("action_value", allow_reuse=True)(_validate_action_value)
|
||||
|
||||
|
||||
class MainMenuButtonUpdateRequest(BaseModel):
|
||||
text: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
action_type: Optional[MainMenuButtonActionType] = None
|
||||
action_value: Optional[str] = Field(None, min_length=1, max_length=1024)
|
||||
visibility: Optional[MainMenuButtonVisibility] = None
|
||||
is_active: Optional[bool] = None
|
||||
display_order: Optional[int] = Field(None, ge=0)
|
||||
|
||||
@validator("text")
|
||||
def validate_text(cls, value): # noqa: D401,B902
|
||||
if value is None:
|
||||
return value
|
||||
return _clean_text(value)
|
||||
|
||||
@validator("action_value")
|
||||
def validate_action_value(cls, value): # noqa: D401,B902
|
||||
if value is None:
|
||||
return value
|
||||
return _validate_action_value(value)
|
||||
|
||||
|
||||
class MainMenuButtonListResponse(BaseModel):
|
||||
items: list[MainMenuButtonResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
|
||||
|
||||
class MiniAppBranding(BaseModel):
|
||||
@@ -34,11 +34,34 @@ class MiniAppSubscriptionUser(BaseModel):
|
||||
traffic_limit_label: str
|
||||
lifetime_used_traffic_gb: float = 0.0
|
||||
has_active_subscription: bool = False
|
||||
promo_offer_discount_percent: int = 0
|
||||
promo_offer_discount_expires_at: Optional[datetime] = None
|
||||
promo_offer_discount_source: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppPromoGroup(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
period_discounts: Dict[int, int] = Field(default_factory=dict)
|
||||
apply_discounts_to_addons: bool = True
|
||||
|
||||
|
||||
class MiniAppAutoPromoGroupLevel(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
threshold_kopeks: int
|
||||
threshold_rubles: float
|
||||
threshold_label: str
|
||||
is_reached: bool = False
|
||||
is_current: bool = False
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
period_discounts: Dict[int, int] = Field(default_factory=dict)
|
||||
apply_discounts_to_addons: bool = True
|
||||
|
||||
|
||||
class MiniAppConnectedServer(BaseModel):
|
||||
@@ -47,6 +70,7 @@ class MiniAppConnectedServer(BaseModel):
|
||||
|
||||
|
||||
class MiniAppDevice(BaseModel):
|
||||
hwid: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
device_model: Optional[str] = None
|
||||
app_version: Optional[str] = None
|
||||
@@ -54,6 +78,16 @@ class MiniAppDevice(BaseModel):
|
||||
last_ip: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppDeviceRemovalRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
hwid: str
|
||||
|
||||
|
||||
class MiniAppDeviceRemovalResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppTransaction(BaseModel):
|
||||
id: int
|
||||
type: str
|
||||
@@ -67,9 +101,329 @@ class MiniAppTransaction(BaseModel):
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppPromoOffer(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
notification_type: Optional[str] = None
|
||||
offer_type: Optional[str] = None
|
||||
effect_type: Optional[str] = None
|
||||
discount_percent: int = 0
|
||||
bonus_amount_kopeks: int = 0
|
||||
bonus_amount_label: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
claimed_at: Optional[datetime] = None
|
||||
is_active: bool = False
|
||||
template_id: Optional[int] = None
|
||||
template_name: Optional[str] = None
|
||||
button_text: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
message_text: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
test_squads: List[MiniAppConnectedServer] = Field(default_factory=list)
|
||||
active_discount_expires_at: Optional[datetime] = None
|
||||
active_discount_started_at: Optional[datetime] = None
|
||||
active_discount_duration_seconds: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppPromoOfferClaimRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
|
||||
class MiniAppPromoOfferClaimResponse(BaseModel):
|
||||
success: bool = True
|
||||
code: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionAutopay(BaseModel):
|
||||
enabled: bool = False
|
||||
autopay_enabled: Optional[bool] = None
|
||||
autopay_enabled_at: Optional[datetime] = None
|
||||
days_before: Optional[int] = None
|
||||
autopay_days_before: Optional[int] = None
|
||||
default_days_before: Optional[int] = None
|
||||
autopay_days_options: List[int] = Field(default_factory=list)
|
||||
days_options: List[int] = Field(default_factory=list)
|
||||
options: List[int] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class MiniAppSubscriptionRenewalPeriod(BaseModel):
|
||||
id: str
|
||||
days: Optional[int] = None
|
||||
months: Optional[int] = None
|
||||
price_kopeks: Optional[int] = Field(default=None, alias="priceKopeks")
|
||||
price_label: Optional[str] = Field(default=None, alias="priceLabel")
|
||||
original_price_kopeks: Optional[int] = Field(default=None, alias="originalPriceKopeks")
|
||||
original_price_label: Optional[str] = Field(default=None, alias="originalPriceLabel")
|
||||
discount_percent: int = Field(default=0, alias="discountPercent")
|
||||
price_per_month_kopeks: Optional[int] = Field(default=None, alias="pricePerMonthKopeks")
|
||||
price_per_month_label: Optional[str] = Field(default=None, alias="pricePerMonthLabel")
|
||||
is_recommended: bool = Field(default=False, alias="isRecommended")
|
||||
description: Optional[str] = None
|
||||
badge: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionRenewalOptionsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionRenewalOptionsResponse(BaseModel):
|
||||
success: bool = True
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
currency: str
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
promo_group: Optional[MiniAppPromoGroup] = Field(default=None, alias="promoGroup")
|
||||
promo_offer: Optional[Dict[str, Any]] = Field(default=None, alias="promoOffer")
|
||||
periods: List[MiniAppSubscriptionRenewalPeriod] = Field(default_factory=list)
|
||||
default_period_id: Optional[str] = Field(default=None, alias="defaultPeriodId")
|
||||
missing_amount_kopeks: Optional[int] = Field(default=None, alias="missingAmountKopeks")
|
||||
status_message: Optional[str] = Field(default=None, alias="statusMessage")
|
||||
autopay_enabled: bool = False
|
||||
autopay_days_before: Optional[int] = None
|
||||
autopay_days_options: List[int] = Field(default_factory=list)
|
||||
autopay: Optional[MiniAppSubscriptionAutopay] = None
|
||||
autopay_settings: Optional[MiniAppSubscriptionAutopay] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class MiniAppSubscriptionRenewalRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
period_id: Optional[str] = Field(default=None, alias="periodId")
|
||||
period_days: Optional[int] = Field(default=None, alias="periodDays")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionRenewalResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
renewed_until: Optional[datetime] = Field(default=None, alias="renewedUntil")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionAutopayRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
enabled: Optional[bool] = None
|
||||
days_before: Optional[int] = Field(default=None, alias="daysBefore")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionAutopayResponse(BaseModel):
|
||||
success: bool = True
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
autopay_enabled: bool = False
|
||||
autopay_days_before: Optional[int] = None
|
||||
autopay_days_options: List[int] = Field(default_factory=list)
|
||||
autopay: Optional[MiniAppSubscriptionAutopay] = None
|
||||
autopay_settings: Optional[MiniAppSubscriptionAutopay] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class MiniAppPromoCode(BaseModel):
|
||||
code: str
|
||||
type: Optional[str] = None
|
||||
balance_bonus_kopeks: int = 0
|
||||
subscription_days: int = 0
|
||||
max_uses: Optional[int] = None
|
||||
current_uses: Optional[int] = None
|
||||
valid_until: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppPromoCodeActivationRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
code: str
|
||||
|
||||
|
||||
class MiniAppPromoCodeActivationResponse(BaseModel):
|
||||
success: bool = True
|
||||
description: Optional[str] = None
|
||||
promocode: Optional[MiniAppPromoCode] = None
|
||||
|
||||
|
||||
class MiniAppFaqItem(BaseModel):
|
||||
id: int
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
display_order: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppFaq(BaseModel):
|
||||
requested_language: str
|
||||
language: str
|
||||
is_enabled: bool = True
|
||||
total: int = 0
|
||||
items: List[MiniAppFaqItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppRichTextDocument(BaseModel):
|
||||
requested_language: str
|
||||
language: str
|
||||
title: Optional[str] = None
|
||||
is_enabled: bool = True
|
||||
content: str = ""
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppLegalDocuments(BaseModel):
|
||||
public_offer: Optional[MiniAppRichTextDocument] = None
|
||||
service_rules: Optional[MiniAppRichTextDocument] = None
|
||||
privacy_policy: Optional[MiniAppRichTextDocument] = None
|
||||
|
||||
|
||||
class MiniAppReferralTerms(BaseModel):
|
||||
minimum_topup_kopeks: int = 0
|
||||
minimum_topup_label: Optional[str] = None
|
||||
first_topup_bonus_kopeks: int = 0
|
||||
first_topup_bonus_label: Optional[str] = None
|
||||
inviter_bonus_kopeks: int = 0
|
||||
inviter_bonus_label: Optional[str] = None
|
||||
commission_percent: float = 0.0
|
||||
referred_user_reward_kopeks: int = 0
|
||||
referred_user_reward_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppReferralStats(BaseModel):
|
||||
invited_count: int = 0
|
||||
paid_referrals_count: int = 0
|
||||
active_referrals_count: int = 0
|
||||
total_earned_kopeks: int = 0
|
||||
total_earned_label: Optional[str] = None
|
||||
month_earned_kopeks: int = 0
|
||||
month_earned_label: Optional[str] = None
|
||||
conversion_rate: float = 0.0
|
||||
|
||||
|
||||
class MiniAppReferralRecentEarning(BaseModel):
|
||||
amount_kopeks: int = 0
|
||||
amount_label: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
referral_name: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppReferralItem(BaseModel):
|
||||
id: int
|
||||
telegram_id: Optional[int] = None
|
||||
full_name: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
last_activity: Optional[datetime] = None
|
||||
has_made_first_topup: bool = False
|
||||
balance_kopeks: int = 0
|
||||
balance_label: Optional[str] = None
|
||||
total_earned_kopeks: int = 0
|
||||
total_earned_label: Optional[str] = None
|
||||
topups_count: int = 0
|
||||
days_since_registration: Optional[int] = None
|
||||
days_since_activity: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppReferralList(BaseModel):
|
||||
total_count: int = 0
|
||||
has_next: bool = False
|
||||
has_prev: bool = False
|
||||
current_page: int = 1
|
||||
total_pages: int = 1
|
||||
items: List[MiniAppReferralItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppReferralInfo(BaseModel):
|
||||
referral_code: Optional[str] = None
|
||||
referral_link: Optional[str] = None
|
||||
terms: Optional[MiniAppReferralTerms] = None
|
||||
stats: Optional[MiniAppReferralStats] = None
|
||||
recent_earnings: List[MiniAppReferralRecentEarning] = Field(default_factory=list)
|
||||
referrals: Optional[MiniAppReferralList] = None
|
||||
|
||||
|
||||
class MiniAppPaymentMethodsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
|
||||
class MiniAppPaymentMethod(BaseModel):
|
||||
id: str
|
||||
icon: Optional[str] = None
|
||||
requires_amount: bool = False
|
||||
currency: str = "RUB"
|
||||
min_amount_kopeks: Optional[int] = None
|
||||
max_amount_kopeks: Optional[int] = None
|
||||
amount_step_kopeks: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppPaymentMethodsResponse(BaseModel):
|
||||
methods: List[MiniAppPaymentMethod] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppPaymentCreateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
method: str
|
||||
amount_rubles: Optional[float] = Field(default=None, alias="amountRubles")
|
||||
amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks")
|
||||
payment_option: Optional[str] = Field(default=None, alias="option")
|
||||
|
||||
|
||||
class MiniAppPaymentCreateResponse(BaseModel):
|
||||
success: bool = True
|
||||
method: str
|
||||
payment_url: Optional[str] = None
|
||||
amount_kopeks: Optional[int] = None
|
||||
extra: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MiniAppPaymentStatusQuery(BaseModel):
|
||||
method: str
|
||||
local_payment_id: Optional[int] = Field(default=None, alias="localPaymentId")
|
||||
invoice_id: Optional[str] = Field(default=None, alias="invoiceId")
|
||||
payment_id: Optional[str] = Field(default=None, alias="paymentId")
|
||||
payload: Optional[str] = None
|
||||
amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks")
|
||||
started_at: Optional[str] = Field(default=None, alias="startedAt")
|
||||
|
||||
|
||||
class MiniAppPaymentStatusRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
payments: List[MiniAppPaymentStatusQuery] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppPaymentStatusResult(BaseModel):
|
||||
method: str
|
||||
status: str
|
||||
is_paid: bool = False
|
||||
amount_kopeks: Optional[int] = None
|
||||
currency: Optional[str] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
transaction_id: Optional[int] = None
|
||||
external_id: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
extra: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MiniAppPaymentStatusResponse(BaseModel):
|
||||
results: List[MiniAppPaymentStatusResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppSubscriptionResponse(BaseModel):
|
||||
success: bool = True
|
||||
subscription_id: int
|
||||
subscription_id: Optional[int] = None
|
||||
remnawave_short_uuid: Optional[str] = None
|
||||
user: MiniAppSubscriptionUser
|
||||
subscription_url: Optional[str] = None
|
||||
@@ -89,8 +443,298 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
balance_rubles: float = 0.0
|
||||
balance_currency: Optional[str] = None
|
||||
transactions: List[MiniAppTransaction] = Field(default_factory=list)
|
||||
promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list)
|
||||
promo_group: Optional[MiniAppPromoGroup] = None
|
||||
auto_assign_promo_groups: List[MiniAppAutoPromoGroupLevel] = Field(default_factory=list)
|
||||
total_spent_kopeks: int = 0
|
||||
total_spent_rubles: float = 0.0
|
||||
total_spent_label: Optional[str] = None
|
||||
subscription_type: str
|
||||
autopay_enabled: bool = False
|
||||
autopay_days_before: Optional[int] = None
|
||||
autopay_days_options: List[int] = Field(default_factory=list)
|
||||
autopay: Optional[MiniAppSubscriptionAutopay] = None
|
||||
autopay_settings: Optional[MiniAppSubscriptionAutopay] = None
|
||||
branding: Optional[MiniAppBranding] = None
|
||||
faq: Optional[MiniAppFaq] = None
|
||||
legal_documents: Optional[MiniAppLegalDocuments] = None
|
||||
referral: Optional[MiniAppReferralInfo] = None
|
||||
subscription_missing: bool = False
|
||||
subscription_missing_reason: Optional[str] = None
|
||||
trial_available: bool = False
|
||||
trial_duration_days: Optional[int] = None
|
||||
trial_status: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class MiniAppSubscriptionServerOption(BaseModel):
|
||||
uuid: str
|
||||
name: Optional[str] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
discount_percent: Optional[int] = None
|
||||
is_connected: bool = False
|
||||
is_available: bool = True
|
||||
disabled_reason: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrafficOption(BaseModel):
|
||||
value: Optional[int] = None
|
||||
label: Optional[str] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
is_current: bool = False
|
||||
is_available: bool = True
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionDeviceOption(BaseModel):
|
||||
value: int
|
||||
label: Optional[str] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionCurrentSettings(BaseModel):
|
||||
servers: List[MiniAppConnectedServer] = Field(default_factory=list)
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
traffic_limit_label: Optional[str] = None
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class MiniAppSubscriptionServersSettings(BaseModel):
|
||||
available: List[MiniAppSubscriptionServerOption] = Field(default_factory=list)
|
||||
min: int = 0
|
||||
max: int = 0
|
||||
can_update: bool = True
|
||||
hint: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrafficSettings(BaseModel):
|
||||
options: List[MiniAppSubscriptionTrafficOption] = Field(default_factory=list)
|
||||
can_update: bool = True
|
||||
current_value: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionDevicesSettings(BaseModel):
|
||||
options: List[MiniAppSubscriptionDeviceOption] = Field(default_factory=list)
|
||||
can_update: bool = True
|
||||
min: int = 0
|
||||
max: int = 0
|
||||
step: int = 1
|
||||
current: int = 0
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionBillingContext(BaseModel):
|
||||
months_remaining: int = 1
|
||||
period_hint_days: Optional[int] = None
|
||||
renews_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettings(BaseModel):
|
||||
subscription_id: int
|
||||
currency: str = "RUB"
|
||||
current: MiniAppSubscriptionCurrentSettings
|
||||
servers: MiniAppSubscriptionServersSettings
|
||||
traffic: MiniAppSubscriptionTrafficSettings
|
||||
devices: MiniAppSubscriptionDevicesSettings
|
||||
billing: Optional[MiniAppSubscriptionBillingContext] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsResponse(BaseModel):
|
||||
success: bool = True
|
||||
settings: MiniAppSubscriptionSettings
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _populate_aliases(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
if "subscriptionId" in values and "subscription_id" not in values:
|
||||
values["subscription_id"] = values["subscriptionId"]
|
||||
return values
|
||||
|
||||
|
||||
class MiniAppSubscriptionServersUpdateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = None
|
||||
servers: Optional[List[str]] = None
|
||||
squads: Optional[List[str]] = None
|
||||
server_uuids: Optional[List[str]] = None
|
||||
squad_uuids: Optional[List[str]] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _populate_aliases(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
alias_map = {
|
||||
"subscriptionId": "subscription_id",
|
||||
"serverUuids": "server_uuids",
|
||||
"squadUuids": "squad_uuids",
|
||||
}
|
||||
for alias, target in alias_map.items():
|
||||
if alias in values and target not in values:
|
||||
values[target] = values[alias]
|
||||
return values
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrafficUpdateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = None
|
||||
traffic: Optional[int] = None
|
||||
traffic_gb: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _populate_aliases(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
alias_map = {
|
||||
"subscriptionId": "subscription_id",
|
||||
"trafficGb": "traffic_gb",
|
||||
}
|
||||
for alias, target in alias_map.items():
|
||||
if alias in values and target not in values:
|
||||
values[target] = values[alias]
|
||||
return values
|
||||
|
||||
|
||||
class MiniAppSubscriptionDevicesUpdateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = None
|
||||
devices: Optional[int] = None
|
||||
device_limit: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _populate_aliases(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
alias_map = {
|
||||
"subscriptionId": "subscription_id",
|
||||
"deviceLimit": "device_limit",
|
||||
}
|
||||
for alias, target in alias_map.items():
|
||||
if alias in values and target not in values:
|
||||
values[target] = values[alias]
|
||||
return values
|
||||
|
||||
|
||||
class MiniAppSubscriptionUpdateResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseOptionsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseOptionsResponse(BaseModel):
|
||||
success: bool = True
|
||||
currency: str
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
data: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchasePreviewRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
selection: Optional[Dict[str, Any]] = None
|
||||
period_id: Optional[str] = Field(default=None, alias="periodId")
|
||||
period_days: Optional[int] = Field(default=None, alias="periodDays")
|
||||
period: Optional[str] = None
|
||||
traffic_value: Optional[int] = Field(default=None, alias="trafficValue")
|
||||
traffic: Optional[int] = None
|
||||
traffic_gb: Optional[int] = Field(default=None, alias="trafficGb")
|
||||
servers: Optional[List[str]] = None
|
||||
countries: Optional[List[str]] = None
|
||||
server_uuids: Optional[List[str]] = Field(default=None, alias="serverUuids")
|
||||
devices: Optional[int] = None
|
||||
device_limit: Optional[int] = Field(default=None, alias="deviceLimit")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _merge_selection(cls, values: Any) -> Any:
|
||||
if not isinstance(values, dict):
|
||||
return values
|
||||
selection = values.get("selection")
|
||||
if isinstance(selection, dict):
|
||||
merged = {**selection, **values}
|
||||
else:
|
||||
merged = dict(values)
|
||||
aliases = {
|
||||
"period_id": ("periodId", "period", "code"),
|
||||
"period_days": ("periodDays",),
|
||||
"traffic_value": ("trafficValue", "traffic", "trafficGb"),
|
||||
"servers": ("countries", "server_uuids", "serverUuids"),
|
||||
"devices": ("deviceLimit",),
|
||||
}
|
||||
for target, sources in aliases.items():
|
||||
if merged.get(target) is not None:
|
||||
continue
|
||||
for source in sources:
|
||||
if source in merged and merged[source] is not None:
|
||||
merged[target] = merged[source]
|
||||
break
|
||||
return merged
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchasePreviewResponse(BaseModel):
|
||||
success: bool = True
|
||||
preview: Dict[str, Any] = Field(default_factory=dict)
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseRequest(MiniAppSubscriptionPurchasePreviewRequest):
|
||||
pass
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrialRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrialResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
trial_status: Optional[str] = Field(default=None, alias="trialStatus")
|
||||
trial_duration_days: Optional[int] = Field(default=None, alias="trialDurationDays")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
@@ -169,3 +169,34 @@ class RemnaWaveGenericSyncResponse(BaseModel):
|
||||
success: bool
|
||||
detail: Optional[str] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationPreviewResponse(BaseModel):
|
||||
squad_uuid: str
|
||||
squad_name: str
|
||||
current_users: int
|
||||
max_users: Optional[int] = None
|
||||
users_to_migrate: int
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationRequest(BaseModel):
|
||||
source_uuid: str
|
||||
target_uuid: str
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationStats(BaseModel):
|
||||
source_uuid: str
|
||||
target_uuid: str
|
||||
total: int = 0
|
||||
updated: int = 0
|
||||
panel_updated: int = 0
|
||||
panel_failed: int = 0
|
||||
source_removed: int = 0
|
||||
target_added: int = 0
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationResponse(BaseModel):
|
||||
success: bool
|
||||
detail: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
data: Optional[RemnaWaveSquadMigrationStats] = None
|
||||
|
||||
@@ -333,6 +333,16 @@
|
||||
"MENU_INFO": "ℹ️ Info",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Info</b>",
|
||||
"MENU_INFO_PROMPT": "Choose a section:",
|
||||
"MENU_PROMO_GROUPS_INFO": "🎯 Promo groups",
|
||||
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Promo groups</b>",
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Spent in the bot: {amount}",
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Current level: {name}",
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Current level: not unlocked yet",
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 Until level “{name}”: spend {amount} more",
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 You already have the highest discount level!",
|
||||
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Auto-assigned levels:",
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — from {amount}",
|
||||
"PROMO_GROUPS_INFO_EMPTY": "Auto-assigned promo groups are not configured yet.",
|
||||
"MENU_FAQ": "❓ FAQ",
|
||||
"FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"FAQ_PAGES_PROMPT": "Select a topic:",
|
||||
@@ -381,6 +391,19 @@
|
||||
"SUBSCRIPTION_NONE": "❌ No active subscription",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_PERIOD": "Period discount: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_TRAFFIC": "Traffic discount: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_SERVERS": "Servers discount: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_DEVICES": "Devices discount: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_PROMO": "Promo offer: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_DISCOUNT_NOTE": "Discount: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_BASE": "Base plan",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC": "Traffic",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_SERVERS": "Servers",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_DEVICES": "Devices",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_PROMO": "Promo discount",
|
||||
"MINIAPP_PURCHASE_SUMMARY_DISCOUNT": "You save {amount}",
|
||||
"MINIAPP_PURCHASE_STATUS_INSUFFICIENT": "Not enough funds on balance",
|
||||
"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",
|
||||
@@ -860,7 +883,42 @@
|
||||
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Manage nodes",
|
||||
"ADMIN_REMNAWAVE_SYNC": "🔄 Synchronization",
|
||||
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Manage squads",
|
||||
"ADMIN_REMNAWAVE_MIGRATION": "🚚 Migration",
|
||||
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Traffic",
|
||||
"ADMIN_SQUAD_MIGRATION_TITLE": "🚚 <b>Squad migration</b>",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE": "Choose the squad you want to migrate from:",
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS": "No squads available. Add new ones or cancel the operation.",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE": "✅ Available",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE": "🚫 Unavailable",
|
||||
"ADMIN_SQUAD_MIGRATION_SERVER_LABEL": "{name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON": "🌍 {name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT": "✅",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT": "🚫",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE": "Page {page}/{pages}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE": "Source: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET": "Choose the destination squad:",
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY": "No other squads available. Cancel or create new squads.",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND": "Squad not found or unavailable.",
|
||||
"ADMIN_SQUAD_MIGRATION_SAME_SQUAD": "You can't choose the same squad.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS": "Review the migration parameters:",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE": "• From: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET": "• To: {target}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT": "• Users to migrate: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT": "Confirm the operation.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON": "✅ Confirm",
|
||||
"ADMIN_SQUAD_MIGRATION_CHANGE_TARGET": "🔄 Change destination",
|
||||
"ADMIN_SQUAD_MIGRATION_IN_PROGRESS": "Starting migration...",
|
||||
"ADMIN_SQUAD_MIGRATION_API_ERROR": "❌ Remnawave API is not configured: {error}",
|
||||
"ADMIN_SQUAD_MIGRATION_ERROR": "❌ Failed to migrate (code: {code}). {details}",
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON": "🔁 New migration",
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON": "⬅️ Back to Remnawave",
|
||||
"ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE": "✅ Migration completed",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_TOTAL": "Subscriptions matched: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_UPDATED": "Migrated: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED": "Updated in panel: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED": "Panel update failed: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CANCELLED": "❌ Migration cancelled.",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE_HINT": "This is the current page.",
|
||||
"ADMIN_STATS_USERS": "👥 Users",
|
||||
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_STATS_REVENUE": "💰 Revenue",
|
||||
@@ -1022,6 +1080,7 @@
|
||||
"ADMIN_PRICING_BUTTON_PERIOD_OPTIONS": "🗓 Available periods",
|
||||
"ADMIN_PRICING_BUTTON_PERIODS": "🗓 Subscription periods",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC": "📦 Traffic packages",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Package visibility",
|
||||
"ADMIN_PRICING_BUTTON_TRIAL": "🎁 Trial period",
|
||||
"ADMIN_PRICING_CHOICE_ALREADY": "This option is already active.",
|
||||
"ADMIN_PRICING_CHOICE_UPDATED": "Selected: {label}",
|
||||
@@ -1038,6 +1097,9 @@
|
||||
"ADMIN_PRICING_PERIOD_DISABLED": "Period disabled.",
|
||||
"ADMIN_PRICING_PERIOD_ENABLED": "Period enabled.",
|
||||
"ADMIN_PRICING_PERIOD_MIN": "At least one period must remain.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Package enabled.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Package disabled.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "At least one package must remain.",
|
||||
"ADMIN_PRICING_SECTION_CORE_TITLE": "⚙️ Core limits",
|
||||
"ADMIN_PRICING_SECTION_CURRENT": "Current values:",
|
||||
"ADMIN_PRICING_SECTION_EMPTY": "No values available.",
|
||||
@@ -1050,6 +1112,10 @@
|
||||
"ADMIN_PRICING_SECTION_PROMPT": "Select what to update:",
|
||||
"ADMIN_PRICING_SECTION_SETTINGS_GENERIC": "⚙️ Settings",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_TITLE": "📦 Traffic packages",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Traffic package visibility",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Active packages: {items}",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "No active packages.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Tap a package to toggle its visibility.",
|
||||
"ADMIN_PRICING_SECTION_TRIAL_TITLE": "🎁 Trial period",
|
||||
"ADMIN_PRICING_SETTING_CANCEL_HINT": "Reply \"Cancel\" to go back without changes.",
|
||||
"ADMIN_PRICING_SETTING_CURRENT": "Current value",
|
||||
|
||||
+104
@@ -246,6 +246,16 @@
|
||||
"MENU_INFO": "ℹ️ Инфо",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Инфо</b>",
|
||||
"MENU_INFO_PROMPT": "Выберите раздел:",
|
||||
"MENU_PROMO_GROUPS_INFO": "🎯 Скидки за траты",
|
||||
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Скидки за траты</b>",
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Потрачено в боте: {amount}",
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Текущий уровень: {name}",
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Текущий уровень: пока не получен",
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 До уровня «{name}»: осталось {amount}",
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 Вы уже получили максимальный уровень скидок!",
|
||||
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Уровни с автовыдачей:",
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — от {amount}",
|
||||
"PROMO_GROUPS_INFO_EMPTY": "Промогруппы с автовыдачей ещё не настроены.",
|
||||
"MENU_FAQ": "❓ FAQ",
|
||||
"FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"FAQ_PAGES_PROMPT": "Выберите вопрос:",
|
||||
@@ -256,6 +266,11 @@
|
||||
"FAQ_PAGE_TITLE": "<b>{title}</b>",
|
||||
"FAQ_PAGE_FOOTER": "Страница {current} из {total}",
|
||||
"FAQ_BACK_TO_LIST": "⬅️ К списку FAQ",
|
||||
"MENU_PRIVACY_POLICY": "🛡️ Политика конфиденциальности",
|
||||
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
|
||||
"PRIVACY_POLICY_EMPTY_ALERT": "Текст политики конфиденциальности ещё не указан.",
|
||||
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
@@ -349,6 +364,19 @@
|
||||
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_PERIOD": "Скидка на период: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_TRAFFIC": "Скидка на трафик: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_SERVERS": "Скидка на серверы: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_DEVICES": "Скидка на устройства: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_PROMO": "Промо-предложение: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_DISCOUNT_NOTE": "Скидка: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_BASE": "Базовый план",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC": "Трафик",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_SERVERS": "Серверы",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_DEVICES": "Устройства",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_PROMO": "Промо скидка",
|
||||
"MINIAPP_PURCHASE_SUMMARY_DISCOUNT": "Экономия {amount}",
|
||||
"MINIAPP_PURCHASE_STATUS_INSUFFICIENT": "Недостаточно средств на балансе",
|
||||
"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": "🧪 Тестовая подписка",
|
||||
@@ -396,6 +424,8 @@
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_SERVER_RANDOM_POOL": "🎲 Случайный из {count} серверов",
|
||||
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Тестовый сервер",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
@@ -763,6 +793,37 @@
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конфиденциальности",
|
||||
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "Политика конфиденциальности отображается в разделе «Инфо».",
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Отображение политики отключено или текст пустой.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Политика активна и доступна пользователям.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Политика включена, но текст пуст — пользователи не увидят ее.",
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Последнее обновление: {timestamp}",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Предпросмотр текста:</b>",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "Текст пока не задан.",
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Изменить текст",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 Просмотреть текущий текст",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Включить отображение",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Отключить отображение",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ Справка по HTML",
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Текущий текст (предпросмотр):",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Отправьте новый текст политики конфиденциальности. HTML-разметка разрешена.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Используйте /html_help для списка поддерживаемых тегов.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Редактирование политики конфиденциальности",
|
||||
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ Текст политики слишком длинный. Максимум 4000 символов.",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ Ошибка HTML: {error}",
|
||||
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Политика конфиденциальности обновлена.",
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ Назад к настройкам политики",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "Текст политики конфиденциальности ещё не задан.",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Текущий текст политики</b>",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Пользователи увидят полную версию в меню.",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена",
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
|
||||
"ADMIN_SETTINGS_BOT_CONFIG": "🧩 Конфигурация бота",
|
||||
@@ -824,7 +885,42 @@
|
||||
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Управление нодами",
|
||||
"ADMIN_REMNAWAVE_SYNC": "🔄 Синхронизация",
|
||||
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Управление сквадами",
|
||||
"ADMIN_REMNAWAVE_MIGRATION": "🚚 Переезд",
|
||||
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Трафик",
|
||||
"ADMIN_SQUAD_MIGRATION_TITLE": "🚚 <b>Переезд сквадов</b>",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE": "Выберите сквад, из которого нужно переехать:",
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS": "Нет доступных сквадов. Добавьте новые или отмените операцию.",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE": "✅ Доступен",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE": "🚫 Недоступен",
|
||||
"ADMIN_SQUAD_MIGRATION_SERVER_LABEL": "{name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON": "🌍 {name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT": "✅",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT": "🚫",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE": "Стр. {page}/{pages}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE": "Источник: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET": "Выберите сквад, в который нужно переехать:",
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY": "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND": "Сквад не найден или недоступен.",
|
||||
"ADMIN_SQUAD_MIGRATION_SAME_SQUAD": "Нельзя выбрать тот же сквад.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS": "Проверьте параметры переезда:",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE": "• Из: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET": "• В: {target}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT": "• Пользователей к переносу: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT": "Подтвердите выполнение операции.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON": "✅ Подтвердить",
|
||||
"ADMIN_SQUAD_MIGRATION_CHANGE_TARGET": "🔄 Изменить сервер назначения",
|
||||
"ADMIN_SQUAD_MIGRATION_IN_PROGRESS": "Запускаю переезд...",
|
||||
"ADMIN_SQUAD_MIGRATION_API_ERROR": "❌ Remnawave API не настроен: {error}",
|
||||
"ADMIN_SQUAD_MIGRATION_ERROR": "❌ Не удалось выполнить переезд (код: {code}). {details}",
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON": "🔁 Новый переезд",
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON": "⬅️ В Remnawave",
|
||||
"ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE": "✅ Переезд завершен",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_TOTAL": "Найдено подписок: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_UPDATED": "Перенесено: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED": "Обновлено в панели: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED": "Не удалось обновить в панели: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CANCELLED": "❌ Переезд отменен.",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE_HINT": "Это текущая страница.",
|
||||
"ADMIN_STATS_USERS": "👥 Пользователи",
|
||||
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_STATS_REVENUE": "💰 Доходы",
|
||||
@@ -986,6 +1082,7 @@
|
||||
"ADMIN_PRICING_BUTTON_PERIOD_OPTIONS": "🗓 Доступные периоды",
|
||||
"ADMIN_PRICING_BUTTON_PERIODS": "🗓 Периоды подписки",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC": "📦 Пакеты трафика",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Отображение пакетов",
|
||||
"ADMIN_PRICING_BUTTON_TRIAL": "🎁 Пробный период",
|
||||
"ADMIN_PRICING_CHOICE_ALREADY": "Это значение уже активно.",
|
||||
"ADMIN_PRICING_CHOICE_UPDATED": "Выбрано: {label}",
|
||||
@@ -1002,6 +1099,9 @@
|
||||
"ADMIN_PRICING_PERIOD_DISABLED": "Период отключен.",
|
||||
"ADMIN_PRICING_PERIOD_ENABLED": "Период включен.",
|
||||
"ADMIN_PRICING_PERIOD_MIN": "Должен оставаться хотя бы один период.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Пакет включен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Пакет отключен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "Должен оставаться хотя бы один пакет.",
|
||||
"ADMIN_PRICING_SECTION_CORE_TITLE": "⚙️ Базовые лимиты",
|
||||
"ADMIN_PRICING_SECTION_CURRENT": "Текущие значения:",
|
||||
"ADMIN_PRICING_SECTION_EMPTY": "Нет доступных значений.",
|
||||
@@ -1014,6 +1114,10 @@
|
||||
"ADMIN_PRICING_SECTION_PROMPT": "Выберите что изменить:",
|
||||
"ADMIN_PRICING_SECTION_SETTINGS_GENERIC": "⚙️ Настройки",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_TITLE": "📦 Пакеты трафика",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Отображение пакетов трафика",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Активные пакеты: {items}",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "Активных пакетов нет.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Нажмите на пакет, чтобы включить или выключить его отображение.",
|
||||
"ADMIN_PRICING_SECTION_TRIAL_TITLE": "🎁 Пробный период",
|
||||
"ADMIN_PRICING_SETTING_CANCEL_HINT": "Чтобы вернуться без изменений, ответьте «Отмена».",
|
||||
"ADMIN_PRICING_SETTING_CURRENT": "Текущее значение",
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.services.backup_service import backup_service
|
||||
from app.services.reporting_service import reporting_service
|
||||
from app.localization.loader import ensure_locale_templates
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.external_admin_service import ensure_external_admin_token
|
||||
from app.services.broadcast_service import broadcast_service
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
|
||||
@@ -186,6 +187,25 @@ async def main():
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
async with timeline.stage(
|
||||
"Внешняя админка",
|
||||
"🛡️",
|
||||
success_message="Токен внешней админки готов",
|
||||
) as stage:
|
||||
try:
|
||||
bot_user = await bot.get_me()
|
||||
token = await ensure_external_admin_token(
|
||||
bot_user.username,
|
||||
bot_user.id,
|
||||
)
|
||||
if token:
|
||||
stage.log("Токен синхронизирован")
|
||||
else:
|
||||
stage.warning("Не удалось получить токен внешней админки")
|
||||
except Exception as error: # pragma: no cover - защитный блок
|
||||
stage.warning(f"Ошибка подготовки внешней админки: {error}")
|
||||
logger.error("❌ Ошибка подготовки внешней админки: %s", error)
|
||||
|
||||
webhook_needed = (
|
||||
settings.TRIBUTE_ENABLED
|
||||
or settings.is_cryptobot_enabled()
|
||||
|
||||
+14540
-158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,353 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
BACKUP_DIR = ROOT_DIR / 'data' / 'backups'
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
os.environ.setdefault('BOT_TOKEN', 'test-token')
|
||||
|
||||
from app.config import settings
|
||||
from app.webapi.routes import miniapp
|
||||
from app.database.models import PaymentMethod
|
||||
from app.webapi.schemas.miniapp import (
|
||||
MiniAppPaymentCreateRequest,
|
||||
MiniAppPaymentMethodsRequest,
|
||||
MiniAppPaymentStatusQuery,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return 'asyncio'
|
||||
|
||||
|
||||
def test_compute_cryptobot_limits_scale_with_rate():
|
||||
low_rate_min, low_rate_max = miniapp._compute_cryptobot_limits(70.0)
|
||||
high_rate_min, high_rate_max = miniapp._compute_cryptobot_limits(120.0)
|
||||
|
||||
assert low_rate_min == 7000
|
||||
assert low_rate_max == 7000000
|
||||
assert high_rate_min == 12000
|
||||
assert high_rate_max == 12000000
|
||||
assert high_rate_min > low_rate_min
|
||||
assert high_rate_max > low_rate_max
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_payment_link_pal24_uses_selected_option(monkeypatch):
|
||||
monkeypatch.setattr(settings, 'PAL24_ENABLED', True, raising=False)
|
||||
monkeypatch.setattr(settings, 'PAL24_API_TOKEN', 'token', raising=False)
|
||||
monkeypatch.setattr(settings, 'PAL24_SHOP_ID', 'shop', raising=False)
|
||||
monkeypatch.setattr(settings, 'PAL24_MIN_AMOUNT_KOPEKS', 1000, raising=False)
|
||||
monkeypatch.setattr(settings, 'PAL24_MAX_AMOUNT_KOPEKS', 5000000, raising=False)
|
||||
|
||||
captured_calls = []
|
||||
|
||||
class DummyPaymentService:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def create_pal24_payment(self, db, **kwargs):
|
||||
captured_calls.append({'db': db, **kwargs})
|
||||
return {
|
||||
'local_payment_id': 101,
|
||||
'bill_id': 'BILL42',
|
||||
'order_id': 'ORD42',
|
||||
'payment_method': kwargs.get('payment_method'),
|
||||
'sbp_url': 'https://sbp',
|
||||
'card_url': 'https://card',
|
||||
'link_url': 'https://link',
|
||||
}
|
||||
|
||||
async def fake_resolve_user(db, init_data):
|
||||
return types.SimpleNamespace(id=123, language='ru'), {}
|
||||
|
||||
monkeypatch.setattr(miniapp, 'PaymentService', lambda *args, **kwargs: DummyPaymentService())
|
||||
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
|
||||
|
||||
payload = MiniAppPaymentCreateRequest(
|
||||
initData='test',
|
||||
method='pal24',
|
||||
amountKopeks=15000,
|
||||
option='card',
|
||||
)
|
||||
|
||||
response = await miniapp.create_payment_link(payload, db=types.SimpleNamespace())
|
||||
|
||||
assert response.payment_url == 'https://card'
|
||||
assert response.extra['selected_option'] == 'card'
|
||||
assert response.extra['payment_method'] == 'CARD'
|
||||
assert captured_calls and captured_calls[0]['payment_method'] == 'CARD'
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_resolve_yookassa_status_includes_identifiers(monkeypatch):
|
||||
payment = types.SimpleNamespace(
|
||||
id=55,
|
||||
user_id=1,
|
||||
amount_kopeks=15000,
|
||||
currency='RUB',
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
captured_at=None,
|
||||
updated_at=None,
|
||||
created_at=datetime.utcnow(),
|
||||
transaction_id=42,
|
||||
yookassa_payment_id='yk_1',
|
||||
)
|
||||
|
||||
async def fake_get_by_local_id(db, local_id):
|
||||
return payment if local_id == 55 else None
|
||||
|
||||
async def fake_get_by_id(db, payment_id):
|
||||
return None
|
||||
|
||||
stub_module = types.SimpleNamespace(
|
||||
get_yookassa_payment_by_local_id=fake_get_by_local_id,
|
||||
get_yookassa_payment_by_id=fake_get_by_id,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, 'app.database.crud.yookassa', stub_module)
|
||||
|
||||
user = types.SimpleNamespace(id=1)
|
||||
query = MiniAppPaymentStatusQuery(
|
||||
method='yookassa',
|
||||
localPaymentId=55,
|
||||
paymentId='yk_1',
|
||||
amountKopeks=15000,
|
||||
startedAt='2024-01-01T00:00:00Z',
|
||||
payload='payload123',
|
||||
)
|
||||
|
||||
result = await miniapp._resolve_yookassa_payment_status(db=None, user=user, query=query)
|
||||
|
||||
assert result.extra['local_payment_id'] == 55
|
||||
assert result.extra['payment_id'] == 'yk_1'
|
||||
assert result.extra['invoice_id'] == 'yk_1'
|
||||
assert result.extra['payload'] == 'payload123'
|
||||
assert result.extra['started_at'] == '2024-01-01T00:00:00Z'
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_resolve_pal24_status_includes_identifiers(monkeypatch):
|
||||
async def fake_get_pal24_payment_by_bill_id(db, bill_id):
|
||||
return None
|
||||
|
||||
stub_module = types.SimpleNamespace(
|
||||
get_pal24_payment_by_bill_id=fake_get_pal24_payment_by_bill_id,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, 'app.database.crud.pal24', stub_module)
|
||||
|
||||
paid_at = datetime.utcnow()
|
||||
|
||||
payment = types.SimpleNamespace(
|
||||
id=321,
|
||||
user_id=1,
|
||||
amount_kopeks=25000,
|
||||
currency='RUB',
|
||||
is_paid=True,
|
||||
status='PAID',
|
||||
paid_at=paid_at,
|
||||
updated_at=paid_at,
|
||||
created_at=paid_at - timedelta(minutes=1),
|
||||
transaction_id=777,
|
||||
bill_id='BILL99',
|
||||
order_id='ORD99',
|
||||
payment_method='SBP',
|
||||
)
|
||||
|
||||
class StubPal24Service:
|
||||
async def get_pal24_payment_status(self, db, local_id):
|
||||
assert local_id == 321
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'PAID',
|
||||
'remote_status': 'PAID',
|
||||
}
|
||||
|
||||
user = types.SimpleNamespace(id=1)
|
||||
query = MiniAppPaymentStatusQuery(
|
||||
method='pal24',
|
||||
localPaymentId=321,
|
||||
amountKopeks=25000,
|
||||
startedAt='2024-01-01T00:00:00Z',
|
||||
payload='pal24_payload',
|
||||
)
|
||||
|
||||
result = await miniapp._resolve_pal24_payment_status(
|
||||
StubPal24Service(),
|
||||
db=None,
|
||||
user=user,
|
||||
query=query,
|
||||
)
|
||||
|
||||
assert result.status == 'paid'
|
||||
assert result.extra['local_payment_id'] == 321
|
||||
assert result.extra['bill_id'] == 'BILL99'
|
||||
assert result.extra['order_id'] == 'ORD99'
|
||||
assert result.extra['payment_method'] == 'SBP'
|
||||
assert result.extra['payload'] == 'pal24_payload'
|
||||
assert result.extra['started_at'] == '2024-01-01T00:00:00Z'
|
||||
assert result.extra['remote_status'] == 'PAID'
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_payment_link_stars_normalizes_amount(monkeypatch):
|
||||
monkeypatch.setattr(settings, 'TELEGRAM_STARS_ENABLED', True, raising=False)
|
||||
monkeypatch.setattr(settings, 'TELEGRAM_STARS_RATE_RUB', 1000.0, raising=False)
|
||||
monkeypatch.setattr(settings, 'BOT_TOKEN', 'test-token', raising=False)
|
||||
|
||||
captured = {}
|
||||
|
||||
class DummyPaymentService:
|
||||
def __init__(self, bot):
|
||||
captured['bot'] = bot
|
||||
|
||||
async def create_stars_invoice(
|
||||
self,
|
||||
amount_kopeks,
|
||||
description,
|
||||
payload,
|
||||
*,
|
||||
stars_amount=None,
|
||||
):
|
||||
captured['amount_kopeks'] = amount_kopeks
|
||||
captured['description'] = description
|
||||
captured['payload'] = payload
|
||||
captured['stars_amount'] = stars_amount
|
||||
return 'https://invoice.example'
|
||||
|
||||
class DummySession:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
captured['session_closed'] = True
|
||||
|
||||
class DummyBot:
|
||||
def __init__(self, token):
|
||||
captured['bot_token'] = token
|
||||
self.session = DummySession()
|
||||
|
||||
async def fake_resolve_user(db, init_data):
|
||||
return types.SimpleNamespace(id=7, language='ru'), {}
|
||||
|
||||
monkeypatch.setattr(miniapp, 'PaymentService', lambda bot: DummyPaymentService(bot))
|
||||
monkeypatch.setattr(miniapp, 'Bot', DummyBot)
|
||||
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
|
||||
|
||||
payload = MiniAppPaymentCreateRequest(
|
||||
initData='data',
|
||||
method='stars',
|
||||
amountKopeks=101000,
|
||||
)
|
||||
|
||||
response = await miniapp.create_payment_link(payload, db=types.SimpleNamespace())
|
||||
|
||||
assert response.payment_url == 'https://invoice.example'
|
||||
assert response.amount_kopeks == 100000
|
||||
assert response.extra['stars_amount'] == 1
|
||||
assert response.extra['requested_amount_kopeks'] == 101000
|
||||
assert captured['amount_kopeks'] == 100000
|
||||
assert captured['stars_amount'] == 1
|
||||
assert captured['bot_token'] == 'test-token'
|
||||
assert captured.get('session_closed') is True
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_get_payment_methods_exposes_stars_min_amount(monkeypatch):
|
||||
monkeypatch.setattr(settings, 'TELEGRAM_STARS_ENABLED', True, raising=False)
|
||||
monkeypatch.setattr(settings, 'TELEGRAM_STARS_RATE_RUB', 999.99, raising=False)
|
||||
|
||||
async def fake_resolve_user(db, init_data):
|
||||
return types.SimpleNamespace(id=1, language='ru'), {}
|
||||
|
||||
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
|
||||
|
||||
payload = MiniAppPaymentMethodsRequest(initData='abc')
|
||||
|
||||
response = await miniapp.get_payment_methods(payload, db=types.SimpleNamespace())
|
||||
|
||||
stars_method = next((method for method in response.methods if method.id == 'stars'), None)
|
||||
assert stars_method is not None
|
||||
assert stars_method.min_amount_kopeks == 99999
|
||||
assert stars_method.amount_step_kopeks == 99999
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_find_recent_deposit_ignores_transactions_before_attempt():
|
||||
started_at = datetime(2024, 5, 1, 12, 0, 0)
|
||||
|
||||
transaction = types.SimpleNamespace(
|
||||
id=10,
|
||||
amount_kopeks=1000,
|
||||
completed_at=None,
|
||||
created_at=started_at - timedelta(minutes=1),
|
||||
)
|
||||
|
||||
class DummyResult:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
class DummySession:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
async def execute(self, query): # noqa: ARG002
|
||||
return DummyResult(self._value)
|
||||
|
||||
result = await miniapp._find_recent_deposit(
|
||||
DummySession(transaction),
|
||||
user_id=1,
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
amount_kopeks=1000,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_find_recent_deposit_accepts_recent_transactions():
|
||||
started_at = datetime(2024, 5, 1, 12, 0, 0)
|
||||
|
||||
transaction = types.SimpleNamespace(
|
||||
id=11,
|
||||
amount_kopeks=1000,
|
||||
completed_at=started_at + timedelta(seconds=5),
|
||||
created_at=started_at + timedelta(seconds=5),
|
||||
)
|
||||
|
||||
class DummyResult:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
class DummySession:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
async def execute(self, query): # noqa: ARG002
|
||||
return DummyResult(self._value)
|
||||
|
||||
result = await miniapp._find_recent_deposit(
|
||||
DummySession(transaction),
|
||||
user_id=1,
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
amount_kopeks=1000,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
assert result is transaction
|
||||
Reference in New Issue
Block a user