Compare commits
1257 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ad9fd8ae7 | |||
| f9cfaa4bc1 | |||
| e1cc7a2e23 | |||
| 7a0faa4b0a | |||
| a87d067133 | |||
| 42759854c2 | |||
| b2027df7f3 | |||
| ce865a9b2a | |||
| 7dcd11b964 | |||
| 6482284cd8 | |||
| 7f8fbf069b | |||
| 3e6064756e | |||
| 1d1a9e0d78 | |||
| bc6e57765e | |||
| 16031023cb | |||
| 151ce092b9 | |||
| 01d3659ad2 | |||
| 7dd0f5f7b0 | |||
| cfd79f4b23 | |||
| 0cd355a878 | |||
| 344ff65710 | |||
| 1b47f2aa99 | |||
| 1d01a77457 | |||
| aa669fa3cd | |||
| e487776510 | |||
| 9d038f7443 | |||
| 02ebcd368e | |||
| 7e1a2998af | |||
| 0a5383eb80 | |||
| 763d8f2aaa | |||
| ce11ec7c0e | |||
| 1bb0a5c000 | |||
| 2cb6dfb4aa | |||
| 837473b274 | |||
| aab32299ac | |||
| 2a3f1bac76 | |||
| f4ed274623 | |||
| 32c28aedea | |||
| 69927a4db2 | |||
| bdc1df737b | |||
| c8c79ea807 | |||
| d75fc0c60f | |||
| 931b282f5b | |||
| 10bc00d429 | |||
| dd24b7ffde | |||
| 5fa627bd7f | |||
| b8671ef07d | |||
| 7a30aa23dc | |||
| a87d52f2bc | |||
| 5919cfff16 | |||
| e743689b34 | |||
| 37dd5ede9f | |||
| 930aabc166 | |||
| f70ad92efe | |||
| 01fd7a317d | |||
| 507df0af33 | |||
| af91423dec | |||
| 893ff0e8f1 | |||
| cde838864f | |||
| 2eb2661848 | |||
| d7a935906e | |||
| b81400105f | |||
| 8c3e71bdfd | |||
| 025d50675f | |||
| 5f540d907e | |||
| c6175255a5 | |||
| 71facd9624 | |||
| 89a5f5c6f7 | |||
| 7b60be1ec7 | |||
| ed314ca19b | |||
| 0b714893e7 | |||
| 5ca0f5fc27 | |||
| 31fa7b7072 | |||
| 86d7003c33 | |||
| 753d77b4c2 | |||
| 4add298aab | |||
| e76f2f3d50 | |||
| dcb0002f4a | |||
| 332c20fc45 | |||
| 305a3c4490 | |||
| a9ecd5f620 | |||
| 2f0594e361 | |||
| 65b36c3a5f | |||
| f8ef2b9f5a | |||
| 70a283d1f1 | |||
| f2a5032f15 | |||
| 0f5fcdd067 | |||
| 80388be54f | |||
| 47f0b77138 | |||
| 162e7da350 | |||
| 07e1e08b1d | |||
| fc955bd3b2 | |||
| 1b504063d2 | |||
| 174f9b8517 | |||
| 3706d5ceb4 | |||
| 3577054d79 | |||
| afd4fe8d1d | |||
| 1409a0ab8d | |||
| 9df8ca52d6 | |||
| 0902efc007 | |||
| 0b4fea02a1 | |||
| 01580aae74 | |||
| 7e022a1eca | |||
| f31c64602c | |||
| f193b0bfcf | |||
| b961529001 | |||
| afa172f3cc | |||
| 8be1d7e423 | |||
| 8c4db0a0e6 | |||
| 1e56e4eaa6 | |||
| a8da98c35e | |||
| 7ca6942443 | |||
| 3f97d22a76 | |||
| e3dccf2c1b | |||
| d0e25fff3d | |||
| 3699d248b1 | |||
| bffbf3ea39 | |||
| e869c9b167 | |||
| 0b2b109e86 | |||
| 5dd586e0b2 | |||
| c9de084efa | |||
| 81b3c7ed3f | |||
| 80785f22b0 | |||
| b1cb368bf6 | |||
| 9fc977ace3 | |||
| 47acd37449 | |||
| d7fa956f58 | |||
| 98164c2083 | |||
| 81f5ce429e | |||
| 5a3bded4a8 | |||
| 2743a845bd | |||
| 7c222c0aed | |||
| 3222c82b46 | |||
| 9f0f3a367c | |||
| f702356f48 | |||
| 48d799e312 | |||
| 7c05b36b9d | |||
| b398eecbeb | |||
| 5ab80b09a1 | |||
| 535b92d43b | |||
| cbaddd65e0 | |||
| 136cae68f1 | |||
| 38abf49af9 | |||
| 799243a988 | |||
| 6d024716c7 | |||
| 0892f494d9 | |||
| 42b67bdc47 | |||
| 6c41263511 | |||
| 1aab9550ce | |||
| d596b19d96 | |||
| 0863e3023d | |||
| 1ef2e1264f | |||
| c40806a5a3 | |||
| 841c288313 | |||
| cd1a4a4a6e | |||
| e9e61f4892 | |||
| 167cddcd2f | |||
| 5b4c597e9b | |||
| 0cb1c9b580 | |||
| ff4471a22a | |||
| 93d7bd74a3 | |||
| c770948c1f | |||
| 2e1a6faec1 | |||
| 0031c9e2e0 | |||
| 637d2a07e0 | |||
| 988ffbebdb | |||
| 5a795a2ae2 | |||
| 9b6cd74dbf | |||
| daa2f13ca3 | |||
| 386b9ae998 | |||
| e64854dc48 | |||
| 23eed94009 | |||
| f04ffa58e4 | |||
| fc45db6f3c | |||
| b8874772be | |||
| d8c7793a26 | |||
| 10d08d5b40 | |||
| b07a889826 | |||
| a24b4c72e9 | |||
| 5e1d00ad0d | |||
| d33ebb1453 | |||
| 7a2bcf3d2f | |||
| dbb68582e4 | |||
| 479b9bc384 | |||
| 73f3987481 | |||
| 8b38f1e37d | |||
| 6976129972 | |||
| 207e4673c0 | |||
| 160ff7ff9f | |||
| 6c7c57138d | |||
| 12b5a39194 | |||
| 7678150e6a | |||
| 99e35cae4c | |||
| af313a12ed | |||
| 71d18287fe | |||
| 6b24b69b53 | |||
| 9528457b89 | |||
| 69322640c5 | |||
| 3c0703b599 | |||
| d14aa5bd8a | |||
| baa5da243e | |||
| fff01d1ce3 | |||
| 52c8442423 | |||
| 150f9e741a | |||
| 4ae2234b7f | |||
| 71366a8133 | |||
| fb6cda2c63 | |||
| 2d4f9da9a7 | |||
| 7a39abe05c | |||
| 79659ec5fe | |||
| a3457d5853 | |||
| 0392aa5b45 | |||
| 2c68d9ded9 | |||
| a437ebd65b | |||
| e2323755de | |||
| 5e23920723 | |||
| 5e45fc1285 | |||
| ff1556ffcd | |||
| 549e8fa332 | |||
| 8a7fb598fa | |||
| 521c364fbb | |||
| db60f5c5ba | |||
| affc07985e | |||
| 2761255e65 | |||
| 53988c319b | |||
| 777ea3b99b | |||
| 672ce18f38 | |||
| aafcec2716 | |||
| 4e5b607288 | |||
| aae85683a0 | |||
| 9971c43218 | |||
| b4370e9a34 | |||
| 101e27acd6 | |||
| 5b9002896e | |||
| e8ac14b0b7 | |||
| 7cd6c3acb7 | |||
| 7e7795fc4e | |||
| 1f75413abd | |||
| 3c6d674ce5 | |||
| 92efc52f7e | |||
| c558f69d62 | |||
| 168cb5ea38 | |||
| 6a48cc687c | |||
| 9a5b0553c6 | |||
| 7515964599 | |||
| bf5e0ecd4f | |||
| 6d058beade | |||
| 91f557e357 | |||
| 8e6c594a7b | |||
| 94cc04703b | |||
| 9ac621cebd | |||
| 5d9f6064d6 | |||
| 010be14379 | |||
| d864c80457 | |||
| ac04284d85 | |||
| e1377ad7a0 | |||
| 6c7bbba7ed | |||
| 7a70a90d63 | |||
| 58187cecdb | |||
| 649a358560 | |||
| e0f1f221df | |||
| 1342236b23 | |||
| 61681b393e | |||
| 20a36fe869 | |||
| a4fe68bf2a | |||
| 827cdd7805 | |||
| 2479167073 | |||
| 5cc8f7869f | |||
| 330ace039b | |||
| 36ad95dfef | |||
| a7d564b7d1 | |||
| 4582daa1d1 | |||
| c440274c32 | |||
| a02b2f163b | |||
| f5a6dbdeec | |||
| 282bf3aa11 | |||
| d302e02b0a | |||
| da554736ba | |||
| aefbc21033 | |||
| add0fb4fbe | |||
| f1bec83a6f | |||
| a9e15a7cc1 | |||
| 53c0f17200 | |||
| 0c5a28d1c3 | |||
| cedb6b4cc4 | |||
| 5c7ec8c4a2 | |||
| 3bb2a055e7 | |||
| b117df126a | |||
| 610a39e322 | |||
| 71fca0ccf8 | |||
| 2568cf086a | |||
| ae453279bf | |||
| bdb04ab91b | |||
| 3636cd27b6 | |||
| 04279542c5 | |||
| c7666274b8 | |||
| 5b27b8834d | |||
| 4b506cde0c | |||
| 121d254fc0 | |||
| 515c9dc8b1 | |||
| 4e03fa1b71 | |||
| 80752c3004 | |||
| a11d37ef96 | |||
| 14c89c7aab | |||
| b09b47fa49 | |||
| 49b8a96a84 | |||
| 6f800ac60b | |||
| 46dfb59f3d | |||
| d5bbb9f14a | |||
| 7c52811750 | |||
| 2d4b46afbd | |||
| 83093236fc | |||
| 3881e7d1d5 | |||
| 1c3dbd57c5 | |||
| 6f1275f291 | |||
| cfc48ea061 | |||
| 2679172ae4 | |||
| 6a0c276454 | |||
| 5872e3bd3c | |||
| f6041ab055 | |||
| b5c5da552d | |||
| c093ef95ed | |||
| d376a25e64 | |||
| 4ec926d072 | |||
| 65a7df4549 | |||
| 57c252dcbf | |||
| 9b74b40794 | |||
| 0b028e1f13 | |||
| 337b948fda | |||
| 5de187c6cd | |||
| 7150204c71 | |||
| 3d44403318 | |||
| 812f3e983c | |||
| 7549e571fe | |||
| 9d3f096be0 | |||
| fd40cd3ba6 | |||
| 1ba566f30f | |||
| 9b9d17b8e7 | |||
| 3fee600db0 | |||
| fbb1091f8b | |||
| 8513d67a78 | |||
| 45ce615fbe | |||
| 2ab4ca5b09 | |||
| b229dc22ba | |||
| bf49a5c1d5 | |||
| 27b6e974ab | |||
| 9f00b56be0 | |||
| 05177bff92 | |||
| f40f233562 | |||
| d3a351aeba | |||
| fb76d30457 | |||
| afba2137d1 | |||
| 9ef3ca1831 | |||
| 65ae2f5c48 | |||
| 2686dcd30a | |||
| 6bc125a2f4 | |||
| 29c6e296df | |||
| 3d1d064af7 | |||
| ccf1f8a71d | |||
| b83a8c9df0 | |||
| a7bd94f394 | |||
| 96787c27d3 | |||
| e99c731e27 | |||
| 4a4b5bd8ef | |||
| 9d27f1aa6b | |||
| fb010038f1 | |||
| 700f86fe38 | |||
| 582a8b9615 | |||
| 7c9dfc352c | |||
| b86d549695 | |||
| 577f6dfd8e | |||
| eab4cce251 | |||
| 7a099ab255 | |||
| 9fa67f61f3 | |||
| 86ebff4948 | |||
| 821ac4668f | |||
| d0280f0ab9 | |||
| ceb45b4c0b | |||
| 85ea3d97ef | |||
| 5640041e45 | |||
| 1357ddc1fe | |||
| 0765dae178 | |||
| 81f2e20c08 | |||
| b27d34ec92 | |||
| e7fbbd579c | |||
| 7a2a82fa87 | |||
| c3c105619b | |||
| 01e575676f | |||
| b53a43fcc6 | |||
| 6a8ff1c1b4 | |||
| 451ab514cc | |||
| c8b6830704 | |||
| 74ee7149c5 | |||
| 506b96dec4 | |||
| 927d4230a2 | |||
| 69ff737b72 | |||
| d0114a1f29 | |||
| f92a0fd88b | |||
| e7478cc1e1 | |||
| 5814f9757d | |||
| cb7b9a9e22 | |||
| 6dc525dd72 | |||
| 409c8c161b | |||
| 78db3a1e77 | |||
| d0c5ff19e1 | |||
| 6a29765ddf | |||
| 95147fbe03 | |||
| 0137321d24 | |||
| 0521306699 | |||
| 2a10e7d2c7 | |||
| e78e9e1a4d | |||
| 29d0b54b04 | |||
| 64fe3fdbb7 | |||
| 04edca175e | |||
| 840091cb05 | |||
| 473c3704cf | |||
| af630b22c9 | |||
| 29fe2f2016 | |||
| cd5691640e | |||
| 77c217f9ad | |||
| 9e963569bc | |||
| c7114ec359 | |||
| b9586ad72a | |||
| b6e333127c | |||
| 6ea2c2cceb | |||
| af42377c3b | |||
| 82881a4c45 | |||
| 70d50e9c47 | |||
| ddb5c01026 | |||
| e287adcb84 | |||
| 83d041c13b | |||
| 545c5fd749 | |||
| 7f0e2de7c4 | |||
| 1fdf1e49a3 | |||
| 08f82ee511 | |||
| fba217b87f | |||
| 6668a18ad3 | |||
| 11c239f46c | |||
| ac62dee279 | |||
| 29a17a7876 | |||
| 694c934612 | |||
| c01d2bfcd7 | |||
| b3708f99c0 | |||
| f651f921aa | |||
| ada6845036 | |||
| d33b259537 | |||
| 3c2ed147ca | |||
| d46e88c2b5 | |||
| 1477491e6e | |||
| 647dc0bd7c | |||
| f6a45993e9 | |||
| 0766320411 | |||
| 5ebf113fd6 | |||
| 2005d103f8 | |||
| 3935813336 | |||
| ec71fa78c0 | |||
| cd2b0a2d67 | |||
| 68d502804f | |||
| afbd343442 | |||
| 991e5a3112 | |||
| 7d9de6ca5d | |||
| 562bb69082 | |||
| a02416c78b | |||
| 81f56008d4 | |||
| 43a7039479 | |||
| a9f3904524 | |||
| 9136c7cfe3 | |||
| 5595846929 | |||
| 9ebe2971bf | |||
| fadea75314 | |||
| a2009cae39 | |||
| 0187adb6f9 | |||
| 302a028873 | |||
| 77c9c19806 | |||
| 724da2545e | |||
| 079af66d9f | |||
| e701d234f0 | |||
| 1b111975bb | |||
| c874425e0e | |||
| c6c112fd17 | |||
| 57d924e3ba | |||
| 9d5b9ebf82 | |||
| 354358e860 | |||
| 327c9d52d2 | |||
| c03051614d | |||
| ac9e5cd908 | |||
| 585bb56a99 | |||
| 8f33eb0cc6 | |||
| 71515a87de | |||
| 0a4ede8f79 | |||
| 040b33b541 | |||
| 5c3484ee28 | |||
| ef309c3101 | |||
| 51dad7936b | |||
| 097734b31d | |||
| 7185fde9bc | |||
| 75f2cdb04b | |||
| d3c445ed2d | |||
| 804f088435 | |||
| 6c1b70e796 | |||
| a3532e5878 | |||
| a8c1b32912 | |||
| 20b0c8a0e2 | |||
| bc18692d75 | |||
| c05ecf1ecd | |||
| 8381fc52f1 | |||
| a697ffa8f1 | |||
| 4eb36fab6d | |||
| 4b0780b549 | |||
| 1befc2982a | |||
| df1f225405 | |||
| df6feca53c | |||
| bd41efb974 | |||
| a1216bface | |||
| e0969b315a | |||
| 395bccf4e5 | |||
| f6e5cfce54 | |||
| a26e89147b | |||
| 4396020177 | |||
| e2c89ec19e | |||
| 68c125ba14 | |||
| 8c794fc91c | |||
| efbe8475df | |||
| 7835e02d5c | |||
| 21a5499af8 | |||
| dd85db071a | |||
| 813193102c | |||
| 42112d6c8a | |||
| dfe5e36fe0 | |||
| b5d4a54e97 | |||
| 1eaf7204ce | |||
| 8cc37dc439 | |||
| ccafb18122 | |||
| 654b132ae6 | |||
| 815fa0560c | |||
| c4d19810a4 | |||
| 9fbb32eb5b | |||
| 3ce3cde859 | |||
| 4e71dcd2b1 | |||
| acf21fa6d2 | |||
| f2b724a78d | |||
| 03473360c1 | |||
| 27dc68be41 | |||
| b15d78f8af | |||
| d232d14937 | |||
| 927d469db2 | |||
| e20e9e7cec | |||
| 0f097c3108 | |||
| eaa3c80d59 | |||
| 0c49eddfde | |||
| 33882f1e93 | |||
| f94666b68d | |||
| d32a49f801 | |||
| 9596f82ea6 | |||
| 0e3fdf7d61 | |||
| 54cfec6029 | |||
| 9e88eae416 | |||
| 8d32efac73 | |||
| 9f07b6007e | |||
| 2fb3b6c5dc | |||
| 0637576425 | |||
| ee7a445610 | |||
| ba4c7ee7ac | |||
| 4da169eb0f | |||
| 28937abab4 | |||
| b1dcf2779e | |||
| 3219062ce2 | |||
| 2ac72951f8 | |||
| cefb8cba78 | |||
| e869655028 | |||
| ae03ede209 | |||
| a72bb25ea2 | |||
| 0b22bc7056 | |||
| cde84ff20b | |||
| f76114d7dc | |||
| 9708a56fd7 | |||
| a360337660 | |||
| 9f78335651 | |||
| ef8142a99b | |||
| 06d51f9fcf | |||
| 4d94347179 | |||
| 2f6aa0b1e6 | |||
| 536a58d4d3 | |||
| 62f7c07059 | |||
| 0648edb3c4 | |||
| 9b6c235424 | |||
| 2fc35950cf | |||
| d2a5d74239 | |||
| 6b04c6748e | |||
| 10b44cbdb7 | |||
| dde04c716c | |||
| 819f19a7ea | |||
| e7201597ab | |||
| ea61aa7053 | |||
| 14546a8485 | |||
| f55455761f | |||
| b1a7767827 | |||
| 43c04b7e64 | |||
| add679cf6f | |||
| ee173190a0 | |||
| a6e21b47f6 | |||
| cd627e5840 | |||
| fc096e6867 | |||
| b591844c4e | |||
| 9557acb546 | |||
| 1fa6e75bd3 | |||
| 8965e43a55 | |||
| 8e7a6beece | |||
| e9f4fd8007 | |||
| a991c59d58 | |||
| dd0624adf8 | |||
| ab51cbe96a | |||
| 2f7a08deb0 | |||
| a0fa7f986b | |||
| b2d3eebe39 | |||
| a043fc0e46 | |||
| d6eec8787e | |||
| 95eee42047 | |||
| c673d6db4f | |||
| bb32a63d48 | |||
| af5acb272c | |||
| 5cd5ac8710 | |||
| 8bb58b44b3 | |||
| 511ecd506f | |||
| 62fc014bc3 | |||
| 961f564274 | |||
| 984870c78c | |||
| 6196c135d2 | |||
| bf1b8315a8 | |||
| b7eabcf7e7 | |||
| e83ebc4f02 | |||
| 1a4758f4cc | |||
| b80e8b3804 | |||
| e036173670 | |||
| bd728c6466 | |||
| e3aec310a3 | |||
| 1c59ce601e | |||
| 0563f7524f | |||
| cedbe07759 | |||
| 0746b2a5a7 | |||
| e8a72860dc | |||
| abbfe4a7d3 | |||
| 1ad5d9123b | |||
| 57031e4afe | |||
| b54d2434c0 | |||
| 7c2d7b6b4f | |||
| 9e731a5f18 | |||
| cd3832fef0 | |||
| cb6d51ed3d | |||
| 8b75768296 | |||
| 3f0a26b1c7 | |||
| de4f608d09 | |||
| 618327b9f1 | |||
| 54fdad8f0c | |||
| e9bbd68aa1 | |||
| 5df4837087 | |||
| 025e74c462 | |||
| 3208303c23 | |||
| 4cf0c5a971 | |||
| 7519352e79 | |||
| e7f94f9661 | |||
| cb68a612e9 | |||
| 44d4deb5a6 | |||
| 23c73d1f21 | |||
| 607beca837 | |||
| 31a5f8766f | |||
| cfdc829f48 | |||
| c44cefb983 | |||
| 79c2307c92 | |||
| 25f126a5cb | |||
| 45bd54e7b0 | |||
| 4ce43f80b8 | |||
| f7a4cedbbf | |||
| 6f83122e42 | |||
| dbcf3541a8 | |||
| b7adf3e971 | |||
| 037b1f876b | |||
| 4495658fad | |||
| 017b758e6c | |||
| edff6d5102 | |||
| 979fbe305a | |||
| d1edb8f892 | |||
| 0eeda6b9ac | |||
| 7c31c3616e | |||
| fba80b1a0d | |||
| 86dbb317f3 | |||
| 7432d43b55 | |||
| a61fb10784 | |||
| 8d83881cf0 | |||
| d624df5f65 | |||
| a96a7de784 | |||
| fa188e04c5 | |||
| e8c95ece8d | |||
| bc0f4adb7a | |||
| 3eb5a71e8d | |||
| 37c97de5d6 | |||
| 3624832ed8 | |||
| 9ee39d5f71 | |||
| 3a0e558b6f | |||
| ca083c216e | |||
| 8b9c1d1cc6 | |||
| 78830a7884 | |||
| 9fe7d0592f | |||
| 6a3e3c6fc4 | |||
| 54bfff0ed6 | |||
| 6ee305cbba | |||
| 340041ddae | |||
| 8a3628a6c5 | |||
| 8ac3350ac6 | |||
| a507ef9966 | |||
| 113405ed71 | |||
| 1bdf1a0f4c | |||
| ff7883e33c | |||
| 9bd909c1b6 | |||
| 408b4e8a4d | |||
| 0ee9708b4d | |||
| 44fadf6709 | |||
| 7384763466 | |||
| 9b6f3b10c6 | |||
| 0e4007bde6 | |||
| 9fcffc7df1 | |||
| 8e1a88c5cd | |||
| 7bb21c9f77 | |||
| 7333379fcf | |||
| fc040007b4 | |||
| b30a3c7961 | |||
| 62e18156f5 | |||
| 853666af6f | |||
| 3416f7b56c | |||
| d78d82c6ce | |||
| 47bd1be714 | |||
| 1eec919b88 | |||
| 777ad52bad | |||
| 5fcadb5544 | |||
| 0a8af20b58 | |||
| 95163a0e35 | |||
| ac58466c1a | |||
| 5ce14dcd9b | |||
| 21d700e16d | |||
| 5f2d4a5e04 | |||
| 7b90d73007 | |||
| 68c90aec44 | |||
| 9280c9a39a | |||
| 245e31e262 | |||
| c00d7ef4d3 | |||
| 2a652c562b | |||
| 1cc31729eb | |||
| 34e5c20c1a | |||
| c8545fe725 | |||
| a8339d376d | |||
| 47150ccbd0 | |||
| 0a0890d8bb | |||
| 6bcf24e3d8 | |||
| 898008a1bd | |||
| 09af2e1d41 | |||
| 045d44f451 | |||
| 04b2325cb9 | |||
| 504581b0f8 | |||
| 0572f414d4 | |||
| 50736dc005 | |||
| fe4bdb6a04 | |||
| 7d7c5f3972 | |||
| 131a2d18cc | |||
| 47296c5c08 | |||
| c15b7a63fb | |||
| e476afe208 | |||
| b59d422fb0 | |||
| 8b105c2396 | |||
| 9b6e0b26c3 | |||
| 4ca038e186 | |||
| 3e185e7ffe | |||
| 5e27dcdcfb | |||
| 8730a68b40 | |||
| d88f13784a | |||
| 6e471c9094 | |||
| 3201b60ff2 | |||
| d30d1e2a29 | |||
| bd73ad069f | |||
| 2186780c32 | |||
| fbb2e561c5 | |||
| c8aba42d3d | |||
| 05d495e874 | |||
| 427011fe41 | |||
| 2feacccb13 | |||
| dc196fb478 | |||
| 71c219e6ea | |||
| e250812463 | |||
| 477b0b26c4 | |||
| 06323f7168 | |||
| 6b471caa22 | |||
| 6fb28dd363 | |||
| 22dcff47d1 | |||
| 1d9b5ea3c6 | |||
| 08e0b3a657 | |||
| 10b80981b2 | |||
| 348f02f34c | |||
| 9e9e984f36 | |||
| d47ca550ae | |||
| 16f4285948 | |||
| 23cb6dd8d0 | |||
| dfbb6b0058 | |||
| 62f8fdd9bb | |||
| fc114d15a0 | |||
| a9fb8a069d | |||
| 9c6f738382 | |||
| 1a7b2eb908 | |||
| b108cc6b60 | |||
| 92cb1e3971 | |||
| 005fc95433 | |||
| f09771bc1c | |||
| d5b72198d7 | |||
| 991bf1161c | |||
| 1a955d920a | |||
| d575c914a6 | |||
| 90a054e942 | |||
| aac5fabbca | |||
| 3ce5d1650b | |||
| 24879b9cf2 | |||
| 37f27df062 | |||
| b78ed6797a | |||
| f73dcd6f66 | |||
| 0634d01336 | |||
| b43d6a83ee | |||
| 93144eea0e | |||
| 7d078a58e1 | |||
| 224c346bc0 | |||
| 51e937ae0c | |||
| 04073815ca | |||
| ff556ba347 | |||
| 2d751c238c | |||
| 5d27fa2963 | |||
| fe71935d58 | |||
| d0d1b7617a | |||
| 310990ae32 | |||
| b565c32e77 | |||
| f3edb4344b | |||
| ccdcff6a7b | |||
| 1d4090ea7a | |||
| 68c7d35165 | |||
| 221cc05af3 | |||
| 1524970401 | |||
| 32a03c5e4b | |||
| a03036c099 | |||
| 811e8a97b3 | |||
| c7625643c6 | |||
| 5ce87238c8 | |||
| 7d12a327ca | |||
| 8c93637d79 | |||
| 2d0ea02b4b | |||
| 98692ac3bd | |||
| 940b6b93b2 | |||
| 17768c303f | |||
| 441fcd2a36 | |||
| c2b6b4e370 | |||
| 2f8d27b1ec | |||
| dc32f53585 | |||
| 165691412b | |||
| 824d4b3a23 | |||
| 060f0ca199 | |||
| ac43f17a43 | |||
| 54fc1c029c | |||
| d259bcf169 | |||
| da46c18210 | |||
| ace7121a4e | |||
| 61a93f86b3 | |||
| 49bcd18a9a | |||
| 7df64d8edf | |||
| ca63b4d758 | |||
| 3cbb304e9b | |||
| ef5192234a | |||
| f7bb9ac4d9 | |||
| b8bb4f3c3f | |||
| e43ff27639 | |||
| e02123c1ed | |||
| f26df389b4 | |||
| 10d36a829d | |||
| 61e936dd49 | |||
| d0f9a04d5f | |||
| cf6bbffd9b | |||
| 5ad45addfa | |||
| 2af013b75b | |||
| 74bda5c986 | |||
| b290ccfff2 | |||
| 904d2883e8 | |||
| 1a54beed17 | |||
| 701a8d33af | |||
| aa03384012 | |||
| d346c63184 | |||
| b8005aaad7 | |||
| bfa9490425 | |||
| 1f4af21a07 | |||
| f952c0e38d | |||
| d7dab5d30f | |||
| 8407f7cdb1 | |||
| 6c72987201 | |||
| 81b6d3f18f | |||
| 95754ab224 | |||
| 949d964a7f | |||
| 8eab79434a | |||
| 8ab2351995 | |||
| e8802dde55 | |||
| acc5ace234 | |||
| 7b33721305 | |||
| e94f4598b5 | |||
| c66c8b7933 | |||
| 0b934efa09 | |||
| 79e644f6dd | |||
| 5c68a1aca7 | |||
| bde613c4bf | |||
| 184d8c3ba7 | |||
| d48221d57f | |||
| 657fd6d07d | |||
| 1211cd22ad | |||
| 19ed88009d | |||
| 0f30ee87ab | |||
| 4cc6778e45 | |||
| ab00a0d136 | |||
| 3d4f1af9a9 | |||
| 0302176fd2 | |||
| 8910780332 | |||
| 8bea64179a | |||
| 777f90eab1 | |||
| ad0b0f475a | |||
| 499292eab6 | |||
| 21b2f1bd2a | |||
| 3c025b536e | |||
| bded6fe59f | |||
| 344ad8f3c0 | |||
| b43dd3167f | |||
| 79ed9dbab2 | |||
| 38ff59f71f | |||
| 65c85e146d | |||
| 467955e7b3 | |||
| 1a4250a7ed | |||
| e181e4325b | |||
| 1a35237852 | |||
| 90baf3da52 | |||
| 348e4dd4dc | |||
| d2cd96a45c | |||
| 64238e3280 | |||
| fcfac2df1f | |||
| a0734a6eb4 | |||
| 0d7a235715 | |||
| 180788bc7b | |||
| 922681891a | |||
| 257c628cc8 | |||
| dcee842c92 | |||
| 5f02471fd5 | |||
| 82f0f7ce17 | |||
| b805b7eef9 | |||
| ad03fa070a | |||
| 9c354d971f | |||
| 5cc70c4bb8 | |||
| 9db806d387 | |||
| b7f7621d28 | |||
| 822ac2417d | |||
| b3c80a2a82 | |||
| 3e9d606647 | |||
| 0ab89f4956 | |||
| 0090f1c409 | |||
| 1cc37a57c1 | |||
| 7809efa556 | |||
| b4a69e3c85 | |||
| 67c975d9c2 | |||
| c0812a7ee9 | |||
| 8ed755a339 | |||
| 1aae8783ca | |||
| 5997a741fc | |||
| c5d522e6b8 | |||
| fa1d128d76 | |||
| f30516a6c2 | |||
| 1feb0538cf | |||
| 3a791d624f | |||
| fe0f1f7641 | |||
| 212783ae3d | |||
| 7d72fc9555 | |||
| fb4df5cee1 | |||
| 45cd847b44 | |||
| 98fd97dea2 | |||
| e334086ad1 | |||
| 2e792911f1 | |||
| 878b666a0b | |||
| 69c3239469 | |||
| b8d48745c5 | |||
| 6b64e73d6e | |||
| b433a96cd6 | |||
| 0e4c83299d | |||
| c4d1f3eccc | |||
| 22dcf425cb | |||
| edf4132c2d | |||
| a56e14c736 | |||
| 2e3c707745 | |||
| ebd8a10fd9 | |||
| a681a9ff1b | |||
| d11458c4a8 | |||
| 9d6e17b609 | |||
| cb27487021 | |||
| e1e6b40a19 | |||
| 2616292f97 | |||
| 2e1dfa6638 | |||
| 3a730492cb | |||
| c35da4b98d | |||
| 5e98c25020 | |||
| 11a4d5b436 | |||
| 93c733aac0 | |||
| 5b03e7ab8c | |||
| 33912331af | |||
| aacc07835e | |||
| 86df2a1408 | |||
| 018de17c5b | |||
| 66afa2b924 | |||
| 4ee32fe0de | |||
| 5b59ca3c4f | |||
| 0108b0fc22 | |||
| 2e575f82af | |||
| e8af09041d | |||
| 1cf041fb86 | |||
| 63074cb009 | |||
| 89b9a6e04f | |||
| 4efa344e43 | |||
| 2d3d1ba5d3 | |||
| 849d781e92 | |||
| 48a988aa43 | |||
| ec71f733ad | |||
| 051121a366 | |||
| e2c2b9127c | |||
| fdf7263bb0 | |||
| c8dcde0919 | |||
| 9db0cd6328 | |||
| 2375bf51b3 | |||
| 3631b49d6d | |||
| 3938b56802 | |||
| 3c3b793a9f | |||
| 8985a89a9f | |||
| 5750e6a4d2 | |||
| 8d7f73edec | |||
| 4853d5b19b | |||
| 901f5fd90d | |||
| 89e3865019 | |||
| 538b2b52d3 | |||
| f1c9df8471 | |||
| 9e09e97ed8 | |||
| e50355be23 | |||
| bd036fb4a9 | |||
| 0181f8e5fb | |||
| f5eea6798a | |||
| 7c8d0254b2 | |||
| 49c3c1c0c9 | |||
| 38522442d1 | |||
| 0dacd79598 | |||
| 2022654055 | |||
| f3a1de6335 | |||
| 0b5b89ea9a | |||
| 8e21c34c8c | |||
| 400f3b01d6 | |||
| 6a0b2aabaf | |||
| 13a1ddad11 | |||
| ed404a0e54 | |||
| 05acfc0733 | |||
| 969cd6ed90 | |||
| 939c9b9b09 | |||
| 051c0db106 | |||
| e5fbe005f8 | |||
| 63efb31785 | |||
| 32586e177b | |||
| 651dba2648 | |||
| 1bbcf2a95c | |||
| 82af176018 | |||
| c10a4e9f8a | |||
| 9727e07464 | |||
| b780fe2278 | |||
| 947d61f2a8 | |||
| 27725ec1a7 | |||
| 93a29463f4 | |||
| 84cfcf45fa | |||
| 6651e59f63 | |||
| ed46b7c919 | |||
| 88cd9fbc5b | |||
| 46fc5827ca | |||
| 65d386f3b2 | |||
| 9db640931e | |||
| 53e4dec12d | |||
| 174d9f9b84 | |||
| 30ec07f7fe | |||
| dbf1fd8085 | |||
| 32e1f231d3 | |||
| 96b788693d | |||
| a0e00c3735 | |||
| 51e68cf215 | |||
| fa3ed3af81 | |||
| 6e72cbb5b0 | |||
| 5f316f85b3 | |||
| 2abd1fd9df | |||
| fcb5fedf9f | |||
| d35c9ad87e | |||
| 0cc0368b0b | |||
| 00fb154cb9 | |||
| 4441cf79ff | |||
| 7428bd5cf7 | |||
| 76c524e980 | |||
| 5a9fa167c2 | |||
| c61e9c78ba | |||
| e5e3a9e4b5 | |||
| a1f47e13e3 | |||
| 7ff0ed1248 | |||
| 5eef24fb91 | |||
| 05ba26d3ac | |||
| ef68ce19ce | |||
| 2fa84f6dc9 | |||
| 32409d7471 | |||
| 270282a340 | |||
| 87eaa06652 | |||
| 845f03e873 | |||
| 97eabb0604 | |||
| f0d714d787 | |||
| 27a34d6e9f | |||
| a4e97e0317 | |||
| 97dcd0b7cc | |||
| 9b57904cf4 | |||
| 6ebdae0cae | |||
| b9d4a8f8e9 | |||
| 707c3c5daf | |||
| d075abfeea | |||
| d3399c569c | |||
| e36e3f7f8f | |||
| 583e01f5e6 | |||
| 784b5e0af6 | |||
| cc42d33fbe | |||
| 9c3d8fd701 | |||
| d281eeb69d | |||
| b510ffb7d8 | |||
| 6855b7765c | |||
| 9ed1129268 | |||
| 0c4ba3c0d0 | |||
| 70337ac310 | |||
| e981082713 | |||
| 104d98542e | |||
| fe63ef5fc5 | |||
| b1ff964dd6 | |||
| aba1d5dbe5 | |||
| 0af7e4ac68 | |||
| 7d9a572e6e | |||
| de6557d7e7 | |||
| a052c7bb80 | |||
| 8c47f3c49b | |||
| 323ad903e7 | |||
| 8105fe285d | |||
| 82a19025f1 | |||
| e592b3e5c4 | |||
| 2da12354a1 | |||
| fc65b62d65 | |||
| 28603a87e1 | |||
| 8956aafc9a | |||
| 2ee9b263b8 | |||
| 3a5d838be3 | |||
| bf25fcfda6 | |||
| ecdf2fcae4 | |||
| 6fec14de4b | |||
| 7b4cbb27b7 | |||
| d7d7795257 | |||
| 93554c7034 | |||
| fbe7febdde | |||
| f091b3ea5e | |||
| aae469853e | |||
| dac68ccd8c | |||
| fb140e82f6 | |||
| 8cac442de1 | |||
| 8d8a465c43 | |||
| 51f2c660e8 | |||
| 39ecca306d | |||
| 7896a24e13 | |||
| c21325f260 | |||
| 18df7395a2 | |||
| f139251333 | |||
| 369e384b7d | |||
| 008f62e02b | |||
| 6e8981a245 | |||
| bb17cf7361 | |||
| d13c6767e6 | |||
| bf2ee37f33 | |||
| b82929ac3d | |||
| 9b0ea9bf2a | |||
| 5bde7196dc | |||
| 71c3e7fe0d | |||
| 201543c263 | |||
| 5ce7417c80 | |||
| e98564b5ab | |||
| 37dd48cc20 | |||
| 9dff6e8045 | |||
| 572094050e | |||
| 2b41337bbf | |||
| ca94c402bd | |||
| 6fdc46c3c6 | |||
| 34fc568f97 | |||
| 38664ff853 | |||
| e44284d4ba | |||
| d54dc822cc | |||
| dbe9c2d5e9 | |||
| b036d090c6 | |||
| d44b303dd3 | |||
| e69bfdac20 | |||
| 53f6c4e71b | |||
| c67223a332 | |||
| 2c253382d5 | |||
| 02b65cb1c9 | |||
| 49ace78a68 | |||
| 0669086742 | |||
| 2f2d9bc1af | |||
| 9b2b11ee44 | |||
| d318738596 | |||
| 6ae41369e9 | |||
| ce9f2e0d41 | |||
| aa7bd42375 | |||
| a6b19e4b95 | |||
| 7a21518ce2 | |||
| ba92f7da6c | |||
| f62f37581f | |||
| 0d08c2c7f0 | |||
| 078e59f09c | |||
| ab708a78a8 | |||
| 630688ecf5 | |||
| 2b6901e87a | |||
| 0e43daf48b | |||
| e9badff1d8 | |||
| f588191931 | |||
| e17dfbff35 | |||
| b7e9770588 | |||
| 9371524257 | |||
| c1f8fe69cf | |||
| 87e3449444 | |||
| e8ef1bc255 | |||
| fe3a593da0 | |||
| bf96d85edb | |||
| 700d718da6 | |||
| 0c9ad3cec8 | |||
| 73d789d3f7 | |||
| 0a09ae4290 | |||
| 0758988b5f | |||
| 089f668e14 | |||
| 1e5bf143c4 | |||
| d1132c82f8 | |||
| 62545276e4 | |||
| 2878bc3c3d | |||
| eed0fe39e7 | |||
| 6bbee5586f | |||
| 7ac2354197 | |||
| 3463f08162 | |||
| 4b0aaa02c7 | |||
| a946f7b501 | |||
| 7f2e4615ee | |||
| 28ab7719f5 | |||
| 31b5101f64 | |||
| 62777f8995 | |||
| f9cff3e169 | |||
| 04c255d748 | |||
| aae771764e | |||
| 7be8757964 |
+121
-10
@@ -19,10 +19,25 @@ ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
|
||||
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
||||
|
||||
# Мониторинг трафика
|
||||
TRAFFIC_MONITORING_ENABLED=false # Включить мониторинг трафика пользователей
|
||||
TRAFFIC_THRESHOLD_GB_PER_DAY=10.0 # Порог трафика в ГБ за сутки (превышение вызывает уведомление)
|
||||
TRAFFIC_MONITORING_INTERVAL_HOURS=24 # Интервал проверки трафика в часах (например: 1, 6, 12, 24)
|
||||
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности (0 для отправки в основной чат)
|
||||
|
||||
# Черный список
|
||||
BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку
|
||||
BLACKLIST_GITHUB_URL=https://raw.githubusercontent.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/refs/heads/main/blacklist.txt # URL к файлу черного списка на GitHub
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновления черного списка с GitHub (в часах)
|
||||
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
|
||||
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
@@ -67,6 +82,14 @@ REMNAWAVE_SECRET_KEY=
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# Шаблон имени пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры аналогичны описанию выше
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
|
||||
|
||||
# Режим удаления пользователей из панели RemnaWave
|
||||
# delete - полностью удалить пользователя из панели
|
||||
# disable - только деактивировать пользователя
|
||||
@@ -77,7 +100,8 @@ REMNAWAVE_USER_DELETE_MODE=delete
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
@@ -113,9 +137,8 @@ FIXED_TRAFFIC_LIMIT_GB=100
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
|
||||
# Включить упрощённую покупку из меню
|
||||
SIMPLE_SUBSCRIPTION_ENABLED=false
|
||||
# ===== ПРОСТАЯ ПОКУПКА ПОДПИСКИ =====
|
||||
SIMPLE_SUBSCRIPTION_ENABLED=true
|
||||
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
|
||||
# Сколько устройств выдаётся в рамках простой подписки
|
||||
@@ -146,6 +169,12 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=10000
|
||||
# Включить выбор количества устройств при покупке и продлении
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW=false
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
@@ -154,6 +183,7 @@ REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
|
||||
# Уведомления
|
||||
REFERRAL_NOTIFICATIONS_ENABLED=true
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
@@ -167,8 +197,8 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
TELEGRAM_STARS_ENABLED=false
|
||||
TELEGRAM_STARS_RATE_RUB=1.79
|
||||
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
@@ -176,6 +206,8 @@ TRIBUTE_API_KEY=
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_HOST=0.0.0.0
|
||||
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
|
||||
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
@@ -194,7 +226,7 @@ YOOKASSA_VAT_CODE=1
|
||||
# 1 - НДС не облагается
|
||||
# 2 - НДС 0%
|
||||
# 3 - НДС 10%
|
||||
# 4 - НДС 20%
|
||||
# 4 - НДС 20%/22%
|
||||
# 5 - НДС 10/110
|
||||
# 6 - НДС 20/120
|
||||
|
||||
@@ -228,7 +260,6 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
@@ -239,9 +270,25 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
# Отключить пополнение баланса через поддержку
|
||||
SUPPORT_TOPUP_ENABLED=true
|
||||
|
||||
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
||||
# Интервал (в минутах) между автоматическими проверками пополнений
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
|
||||
|
||||
# ===== НАЛОГОВАЯ СЛУЖБА (NaloGO) =====
|
||||
# Автоматическая отправка чеков в налоговую при пополнении баланса
|
||||
NALOGO_ENABLED=false
|
||||
NALOGO_RECEIPTS_ENABLED=false
|
||||
NALOGO_INN= # ИНН самозанятого
|
||||
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
|
||||
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
|
||||
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
@@ -256,11 +303,29 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
# Эти порты используются только при ручном запуске старого aiohttp сервера.
|
||||
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
|
||||
CRYPTOBOT_WEBHOOK_PORT=8081
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# HELEKET
|
||||
HELEKET_ENABLED=false
|
||||
HELEKET_MERCHANT_ID=
|
||||
HELEKET_API_KEY=
|
||||
HELEKET_BASE_URL=https://api.heleket.com/v1
|
||||
HELEKET_DEFAULT_CURRENCY=USDT
|
||||
HELEKET_DEFAULT_NETWORK=
|
||||
HELEKET_INVOICE_LIFETIME=3600
|
||||
HELEKET_MARKUP_PERCENT=0
|
||||
HELEKET_WEBHOOK_PATH=/heleket-webhook
|
||||
HELEKET_WEBHOOK_HOST=0.0.0.0
|
||||
HELEKET_WEBHOOK_PORT=8086
|
||||
HELEKET_CALLBACK_URL=
|
||||
HELEKET_RETURN_URL=
|
||||
HELEKET_SUCCESS_URL=
|
||||
|
||||
# MULENPAY
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
@@ -294,6 +359,22 @@ PAL24_SBP_BUTTON_VISIBLE=true
|
||||
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
|
||||
PAL24_CARD_BUTTON_VISIBLE=true
|
||||
|
||||
# PLATEGA
|
||||
PLATEGA_ENABLED=false
|
||||
PLATEGA_MERCHANT_ID=
|
||||
PLATEGA_SECRET=
|
||||
PLATEGA_BASE_URL=https://app.platega.io
|
||||
PLATEGA_RETURN_URL=
|
||||
PLATEGA_FAILED_URL=
|
||||
PLATEGA_CURRENCY=RUB
|
||||
# Список ID активных методов из кабинета Platega (через запятую)
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=100
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
PLATEGA_WEBHOOK_HOST=0.0.0.0
|
||||
PLATEGA_WEBHOOK_PORT=8086
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
@@ -316,6 +397,7 @@ CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
|
||||
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
|
||||
@@ -367,14 +449,18 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MONITORING_ENABLED=true
|
||||
MAINTENANCE_RETRY_ATTEMPTS=1
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
# Часовой пояс
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
@@ -412,3 +498,28 @@ LOG_FILE=logs/bot.log
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
CONTESTS_BUTTON_VISIBLE=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
# ACTIVATE_BUTTON_TEXT=активировать
|
||||
|
||||
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
|
||||
WEB_API_ENABLED=false
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8080
|
||||
WEB_API_ALLOWED_ORIGINS=*
|
||||
WEB_API_DOCS_ENABLED=false
|
||||
WEB_API_DEFAULT_TOKEN=
|
||||
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: Bug Bedolage
|
||||
about: Создать баг-репорт «Бедолаге»
|
||||
title: "[BUG] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем текущую проблему.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые имеются):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Шаги для воспроизведения
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Фактический результат
|
||||
|
||||
*
|
||||
*
|
||||
*
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
Опишите, как должно работать.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Feat Bedolage
|
||||
about: Создать запрос на фичу «Бедолаге»
|
||||
title: "[FEAT] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем что хотим.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые к месту):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Текущее поведение
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Желаемое поведение
|
||||
|
||||
Опишите, как хотелось бы, чтобы было.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -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.5.3-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.5.3-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
docker-compose.override.yml
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
@@ -11,6 +12,8 @@
|
||||
!requirements.txt
|
||||
!docs/
|
||||
!docs/**
|
||||
!migrations/
|
||||
!migrations/**
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.3"
|
||||
ARG VERSION="v2.9.2"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8080 8081 8082
|
||||
EXPOSE 8080
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
@@ -57,6 +57,6 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.vendor="fr1ngg"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
"Commons Clause" License Condition v1.0
|
||||
|
||||
The Software is provided to you by the Licensor under the License,
|
||||
as defined below, subject to the following condition.
|
||||
|
||||
Without limiting other conditions in the License, the grant of rights
|
||||
under the License will not include, and the License does not grant to
|
||||
you, the right to Sell the Software.
|
||||
|
||||
For purposes of the foregoing, "Sell" means practicing any or all of
|
||||
the rights granted to you under the License to provide to third parties,
|
||||
for a fee or other consideration (including without limitation fees for
|
||||
hosting or consulting/support services related to the Software), a
|
||||
product or service whose value derives, entirely or substantially, from
|
||||
the functionality of the Software.
|
||||
|
||||
Any license notice or attribution required by the License must also
|
||||
include this Commons Clause License Condition notice.
|
||||
|
||||
Software: remnawave-bedolaga-telegram-bot
|
||||
License: MIT
|
||||
Licensor: Fr1ngg
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Fr1ngg
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.PHONY: up
|
||||
up: ## Поднять контейнеры (detached)
|
||||
@echo "🚀 Поднимаем контейнеры (detached)..."
|
||||
docker compose up -d --build
|
||||
|
||||
.PHONY: up-follow
|
||||
up-follow: ## Поднять контейнеры с логами
|
||||
@echo "📡 Поднимаем контейнеры (в консоли)..."
|
||||
docker compose up --build
|
||||
|
||||
.PHONY: down
|
||||
down: ## Остановить и удалить контейнеры
|
||||
@echo "🛑 Останавливаем и удаляем контейнеры..."
|
||||
docker compose down
|
||||
|
||||
.PHONY: reload
|
||||
reload: ## Перезапустить контейнеры (detached)
|
||||
@$(MAKE) down
|
||||
@$(MAKE) up
|
||||
|
||||
.PHONY: reload-follow
|
||||
reload-follow: ## Перезапустить контейнеры с логами
|
||||
@$(MAKE) down
|
||||
@$(MAKE) up-follow
|
||||
|
||||
.PHONY: test
|
||||
test: ## Запустить тесты
|
||||
@echo "🧪 Запускаем тесты..."
|
||||
pytest -v
|
||||
|
||||
.PHONY: help
|
||||
help: ## Показать список доступных команд
|
||||
@echo ""
|
||||
@echo "📘 Команды Makefile:"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
|
||||
sed -E 's/:.*?## /| /' | \
|
||||
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
+2
-4
@@ -136,8 +136,7 @@ logger.debug(f"Webhook payload: {webhook_data}")
|
||||
# Открыть только необходимые порты
|
||||
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
|
||||
ufw deny 8082/tcp
|
||||
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
|
||||
```
|
||||
|
||||
### 📊 Мониторинг безопасности
|
||||
@@ -239,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
|
||||
ADMIN_IDS=123456789,987654321
|
||||
|
||||
# Webhook секреты - генерируйте случайные значения
|
||||
YOOKASSA_WEBHOOK_SECRET=random_secret_here
|
||||
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
||||
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
||||
```
|
||||
@@ -283,7 +281,7 @@ services:
|
||||
|
||||
# Не пробрасывайте порты наружу без необходимости
|
||||
ports:
|
||||
- "127.0.0.1:8081:8081" # Только localhost
|
||||
- "127.0.0.1:8080:8080" # Только localhost
|
||||
|
||||
# Read-only root filesystem
|
||||
read_only: true
|
||||
|
||||
+43
-7
@@ -12,16 +12,28 @@ from app.middlewares.throttling import ThrottlingMiddleware
|
||||
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
start,
|
||||
menu,
|
||||
subscription,
|
||||
balance,
|
||||
promocode,
|
||||
referral,
|
||||
support,
|
||||
server_status,
|
||||
common,
|
||||
tickets,
|
||||
)
|
||||
from app.handlers import polls as user_polls
|
||||
from app.handlers import simple_subscription
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
blacklist as admin_blacklist,
|
||||
bulk_ban as admin_bulk_ban,
|
||||
users as admin_users,
|
||||
subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes,
|
||||
@@ -31,10 +43,13 @@ from app.handlers.admin import (
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
polls as admin_polls,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
contests as admin_contests,
|
||||
daily_contests as admin_daily_contests,
|
||||
promo_offers as admin_promo_offers,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates,
|
||||
@@ -48,7 +63,10 @@ from app.handlers.admin import (
|
||||
privacy_policy as admin_privacy_policy,
|
||||
public_offer as admin_public_offer,
|
||||
faq as admin_faq,
|
||||
payments as admin_payments,
|
||||
trials as admin_trials,
|
||||
)
|
||||
from app.handlers import contests as user_contests
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
from app.utils.message_patch import patch_message_methods
|
||||
@@ -110,6 +128,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
# Middleware для автоматического логирования кликов по кнопкам
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
button_stats_middleware = ButtonStatsMiddleware()
|
||||
dp.callback_query.middleware(button_stats_middleware)
|
||||
logger.info("📊 ButtonStatsMiddleware активирован")
|
||||
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
@@ -145,8 +169,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_polls.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_contests.register_handlers(dp)
|
||||
admin_daily_contests.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
@@ -161,18 +188,27 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_privacy_policy.register_handlers(dp)
|
||||
admin_public_offer.register_handlers(dp)
|
||||
admin_faq.register_handlers(dp)
|
||||
admin_payments.register_handlers(dp)
|
||||
admin_trials.register_handlers(dp)
|
||||
admin_bulk_ban.register_bulk_ban_handlers(dp)
|
||||
admin_blacklist.register_blacklist_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
if settings.is_maintenance_monitoring_enabled():
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
else:
|
||||
logger.info("Мониторинг техработ отключен настройками")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
+504
-66
@@ -1,12 +1,15 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import html
|
||||
from collections import defaultdict
|
||||
from datetime import time
|
||||
from typing import List, Optional, Union, Dict
|
||||
from typing import Dict, List, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
@@ -17,6 +20,11 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
|
||||
"joingroup",
|
||||
]
|
||||
|
||||
USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
@@ -46,6 +54,7 @@ class Settings(BaseSettings):
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
|
||||
|
||||
DATABASE_URL: Optional[str] = None
|
||||
|
||||
@@ -57,6 +66,8 @@ class Settings(BaseSettings):
|
||||
|
||||
SQLITE_PATH: str = "./data/bot.db"
|
||||
LOCALES_PATH: str = "./locales"
|
||||
|
||||
TIMEZONE: str = Field(default_factory=lambda: os.getenv("TZ", "UTC"))
|
||||
|
||||
DATABASE_MODE: str = "auto"
|
||||
|
||||
@@ -70,6 +81,7 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_PASSWORD: Optional[str] = None
|
||||
REMNAWAVE_AUTH_TYPE: str = "api_key"
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}"
|
||||
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
|
||||
REMNAWAVE_AUTO_SYNC_ENABLED: bool = False
|
||||
REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00"
|
||||
@@ -78,9 +90,11 @@ class Settings(BaseSettings):
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
TRIAL_USER_TAG: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
@@ -110,6 +124,7 @@ class Settings(BaseSettings):
|
||||
PRICE_90_DAYS: int = 269000
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
PAID_SUBSCRIPTION_USER_TAG: Optional[str] = None
|
||||
|
||||
PRICE_TRAFFIC_5GB: int = 2000
|
||||
PRICE_TRAFFIC_10GB: int = 3500
|
||||
@@ -120,10 +135,12 @@ class Settings(BaseSettings):
|
||||
PRICE_TRAFFIC_500GB: int = 19000
|
||||
PRICE_TRAFFIC_1000GB: int = 19500
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 20000
|
||||
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG: str = ""
|
||||
|
||||
PRICE_PER_DEVICE: int = 5000
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
|
||||
@@ -139,20 +156,51 @@ class Settings(BaseSettings):
|
||||
REFERRAL_PROGRAM_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
REFERRED_USER_REWARD: int = 0
|
||||
|
||||
|
||||
# Конкурсы (глобальный флаг, будет расширяться под разные типы)
|
||||
CONTESTS_ENABLED: bool = False
|
||||
CONTESTS_BUTTON_VISIBLE: bool = False
|
||||
# Для обратной совместимости со старыми конфигами
|
||||
REFERRAL_CONTESTS_ENABLED: bool = False
|
||||
|
||||
BLACKLIST_CHECK_ENABLED: bool = False
|
||||
BLACKLIST_GITHUB_URL: Optional[str] = None
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
|
||||
BLACKLIST_IGNORE_ADMINS: bool = True
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = True
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
|
||||
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
|
||||
|
||||
# Настройки конструктора меню (API)
|
||||
MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API
|
||||
|
||||
# Настройки мониторинга трафика
|
||||
TRAFFIC_MONITORING_ENABLED: bool = False
|
||||
TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки
|
||||
TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (по умолчанию - раз в сутки)
|
||||
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
ENABLE_AUTOPAY: bool = False
|
||||
|
||||
DEFAULT_AUTOPAY_ENABLED: bool = False
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
|
||||
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000
|
||||
|
||||
MONITORING_INTERVAL: int = 60
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_MONITORING_ENABLED: bool = True
|
||||
MAINTENANCE_RETRY_ATTEMPTS: int = 1
|
||||
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
|
||||
|
||||
TELEGRAM_STARS_ENABLED: bool = True
|
||||
@@ -177,18 +225,28 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ""
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
|
||||
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
|
||||
SUPPORT_TOPUP_ENABLED: bool = True
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
|
||||
NALOGO_ENABLED: bool = False
|
||||
NALOGO_RECEIPTS_ENABLED: bool = False
|
||||
NALOGO_INN: Optional[str] = None
|
||||
NALOGO_PASSWORD: Optional[str] = None
|
||||
NALOGO_DEVICE_ID: Optional[str] = None
|
||||
NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json"
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW: bool = False
|
||||
ACTIVATE_BUTTON_VISIBLE: bool = False
|
||||
ACTIVATE_BUTTON_TEXT: str = "активировать"
|
||||
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
|
||||
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
|
||||
@@ -206,6 +264,21 @@ class Settings(BaseSettings):
|
||||
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
|
||||
|
||||
HELEKET_ENABLED: bool = False
|
||||
HELEKET_MERCHANT_ID: Optional[str] = None
|
||||
HELEKET_API_KEY: Optional[str] = None
|
||||
HELEKET_BASE_URL: str = "https://api.heleket.com/v1"
|
||||
HELEKET_DEFAULT_CURRENCY: str = "USDT"
|
||||
HELEKET_DEFAULT_NETWORK: Optional[str] = None
|
||||
HELEKET_INVOICE_LIFETIME: int = 3600
|
||||
HELEKET_MARKUP_PERCENT: float = 0.0
|
||||
HELEKET_WEBHOOK_PATH: str = "/heleket-webhook"
|
||||
HELEKET_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
HELEKET_WEBHOOK_PORT: int = 8086
|
||||
HELEKET_CALLBACK_URL: Optional[str] = None
|
||||
HELEKET_RETURN_URL: Optional[str] = None
|
||||
HELEKET_SUCCESS_URL: Optional[str] = None
|
||||
|
||||
MULENPAY_ENABLED: bool = False
|
||||
MULENPAY_API_KEY: Optional[str] = None
|
||||
MULENPAY_SECRET_KEY: Optional[str] = None
|
||||
@@ -224,6 +297,7 @@ class Settings(BaseSettings):
|
||||
MULENPAY_PAYMENT_MODE: int = 4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None
|
||||
|
||||
PAL24_ENABLED: bool = False
|
||||
PAL24_API_TOKEN: Optional[str] = None
|
||||
@@ -241,6 +315,20 @@ class Settings(BaseSettings):
|
||||
PAL24_SBP_BUTTON_VISIBLE: bool = True
|
||||
PAL24_CARD_BUTTON_VISIBLE: bool = True
|
||||
|
||||
PLATEGA_ENABLED: bool = False
|
||||
PLATEGA_MERCHANT_ID: Optional[str] = None
|
||||
PLATEGA_SECRET: Optional[str] = None
|
||||
PLATEGA_BASE_URL: str = "https://app.platega.io"
|
||||
PLATEGA_RETURN_URL: Optional[str] = None
|
||||
PLATEGA_FAILED_URL: Optional[str] = None
|
||||
PLATEGA_CURRENCY: str = "RUB"
|
||||
PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13"
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PLATEGA_WEBHOOK_PATH: str = "/platega-webhook"
|
||||
PLATEGA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
PLATEGA_WEBHOOK_PORT: int = 8086
|
||||
|
||||
WATA_ENABLED: bool = False
|
||||
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
|
||||
WATA_ACCESS_TOKEN: Optional[str] = None
|
||||
@@ -262,6 +350,7 @@ class Settings(BaseSettings):
|
||||
MAIN_MENU_MODE: str = "default"
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
MINIAPP_STATIC_PATH: str = "miniapp"
|
||||
MINIAPP_PURCHASE_URL: str = ""
|
||||
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
|
||||
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
|
||||
@@ -290,6 +379,13 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
WEBHOOK_SECRET_TOKEN: Optional[str] = None
|
||||
WEBHOOK_DROP_PENDING_UPDATES: bool = True
|
||||
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
|
||||
WEBHOOK_WORKERS: int = 4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT: float = 0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT: float = 30.0
|
||||
BOT_RUN_MODE: str = "polling"
|
||||
|
||||
WEB_API_ENABLED: bool = False
|
||||
WEB_API_HOST: str = "0.0.0.0"
|
||||
@@ -528,6 +624,34 @@ class Settings(BaseSettings):
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
return description
|
||||
|
||||
def format_remnawave_username(
|
||||
self,
|
||||
*,
|
||||
full_name: str,
|
||||
username: Optional[str],
|
||||
telegram_id: int
|
||||
) -> str:
|
||||
template = self.REMNAWAVE_USER_USERNAME_TEMPLATE or "user_{telegram_id}"
|
||||
|
||||
username_clean = (username or "").lstrip("@")
|
||||
full_name_value = full_name or ""
|
||||
|
||||
values = defaultdict(str, {
|
||||
"full_name": full_name_value,
|
||||
"username": username_clean,
|
||||
"username_clean": username_clean,
|
||||
"telegram_id": str(telegram_id),
|
||||
})
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
sanitized_username = re.sub(r"[^0-9A-Za-z._-]+", "_", raw_username)
|
||||
sanitized_username = re.sub(r"_+", "_", sanitized_username).strip("._-")
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f"user_{telegram_id}"
|
||||
|
||||
return sanitized_username[:64]
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: Optional[str]) -> List[time]:
|
||||
if not raw_value:
|
||||
@@ -606,17 +730,51 @@ class Settings(BaseSettings):
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_auto_purchase_after_topup_enabled(self) -> bool:
|
||||
value = getattr(self, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", False)
|
||||
|
||||
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]:
|
||||
defaults = ["ru", "en", "ua", "zh"]
|
||||
|
||||
try:
|
||||
langs = self.AVAILABLE_LANGUAGES
|
||||
if isinstance(langs, str):
|
||||
if not langs.strip():
|
||||
return ["ru", "en"]
|
||||
return [x.strip() for x in langs.split(',') if x.strip()]
|
||||
return ["ru", "en"]
|
||||
except AttributeError:
|
||||
return ["ru", "en"]
|
||||
return defaults
|
||||
|
||||
candidates: List[str]
|
||||
|
||||
if isinstance(langs, str):
|
||||
if not langs.strip():
|
||||
return defaults
|
||||
candidates = [chunk.strip() for chunk in langs.split(',')]
|
||||
elif isinstance(langs, (list, tuple, set)):
|
||||
candidates = [str(item).strip() for item in langs]
|
||||
else:
|
||||
return defaults
|
||||
|
||||
cleaned: List[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for code in candidates:
|
||||
if not code:
|
||||
continue
|
||||
|
||||
normalized = code.lower()
|
||||
|
||||
if normalized in seen:
|
||||
continue
|
||||
|
||||
seen.add(normalized)
|
||||
cleaned.append(code)
|
||||
|
||||
return cleaned or defaults
|
||||
|
||||
def is_language_selection_enabled(self) -> bool:
|
||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||
@@ -659,13 +817,48 @@ class Settings(BaseSettings):
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
return kopeks / 100
|
||||
|
||||
|
||||
def rubles_to_kopeks(self, rubles: float) -> int:
|
||||
return int(rubles * 100)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _normalize_user_tag(value: Optional[str], setting_name: str) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
cleaned = str(value).strip().upper()
|
||||
if not cleaned:
|
||||
return None
|
||||
|
||||
if len(cleaned) > 16:
|
||||
logger.warning(
|
||||
"Некорректная длина %s: максимум 16 символов, получено %s",
|
||||
setting_name,
|
||||
len(cleaned),
|
||||
)
|
||||
return None
|
||||
|
||||
if not USER_TAG_PATTERN.fullmatch(cleaned):
|
||||
logger.warning(
|
||||
"Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание",
|
||||
setting_name,
|
||||
)
|
||||
return None
|
||||
|
||||
return cleaned
|
||||
|
||||
def get_trial_warning_hours(self) -> int:
|
||||
return self.TRIAL_WARNING_HOURS
|
||||
|
||||
def get_trial_user_tag(self) -> Optional[str]:
|
||||
return self._normalize_user_tag(self.TRIAL_USER_TAG, "TRIAL_USER_TAG")
|
||||
|
||||
def get_paid_subscription_user_tag(self) -> Optional[str]:
|
||||
return self._normalize_user_tag(
|
||||
self.PAID_SUBSCRIPTION_USER_TAG,
|
||||
"PAID_SUBSCRIPTION_USER_TAG",
|
||||
)
|
||||
|
||||
def get_bot_username(self) -> Optional[str]:
|
||||
username = getattr(self, "BOT_USERNAME", None)
|
||||
if not username:
|
||||
@@ -766,14 +959,66 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_traffic_fixed(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
|
||||
|
||||
|
||||
def get_fixed_traffic_limit(self) -> int:
|
||||
return self.FIXED_TRAFFIC_LIMIT_GB
|
||||
|
||||
def is_devices_selection_enabled(self) -> bool:
|
||||
return self.DEVICES_SELECTION_ENABLED
|
||||
|
||||
def get_devices_selection_disabled_amount(self) -> Optional[int]:
|
||||
raw_value = self.DEVICES_SELECTION_DISABLED_AMOUNT
|
||||
|
||||
if raw_value in (None, ""):
|
||||
return None
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s",
|
||||
raw_value,
|
||||
)
|
||||
return None
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
|
||||
return value
|
||||
|
||||
def get_disabled_mode_device_limit(self) -> Optional[int]:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
return bool(self.TRIAL_PAYMENT_ENABLED)
|
||||
|
||||
def get_trial_activation_price(self) -> int:
|
||||
try:
|
||||
value = int(self.TRIAL_ACTIVATION_PRICE)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение TRIAL_ACTIVATION_PRICE: %s",
|
||||
self.TRIAL_ACTIVATION_PRICE,
|
||||
)
|
||||
return 0
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
|
||||
return value
|
||||
|
||||
def is_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
self.YOOKASSA_SHOP_ID is not None and
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
self.YOOKASSA_SHOP_ID is not None and
|
||||
self.YOOKASSA_SECRET_KEY is not None)
|
||||
|
||||
def is_nalogo_enabled(self) -> bool:
|
||||
return (self.NALOGO_ENABLED and
|
||||
self.NALOGO_INN is not None and
|
||||
self.NALOGO_PASSWORD is not None)
|
||||
|
||||
def is_support_topup_enabled(self) -> bool:
|
||||
return bool(self.SUPPORT_TOPUP_ENABLED)
|
||||
|
||||
def get_yookassa_return_url(self) -> str:
|
||||
if self.YOOKASSA_RETURN_URL:
|
||||
@@ -786,6 +1031,13 @@ class Settings(BaseSettings):
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
self.CRYPTOBOT_API_TOKEN is not None)
|
||||
|
||||
def is_heleket_enabled(self) -> bool:
|
||||
return (
|
||||
self.HELEKET_ENABLED
|
||||
and self.HELEKET_MERCHANT_ID is not None
|
||||
and self.HELEKET_API_KEY is not None
|
||||
)
|
||||
|
||||
def is_mulenpay_enabled(self) -> bool:
|
||||
return (
|
||||
self.MULENPAY_ENABLED
|
||||
@@ -803,6 +1055,20 @@ class Settings(BaseSettings):
|
||||
def get_mulenpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_mulenpay_display_name())
|
||||
|
||||
def get_mulenpay_expected_origin(self) -> Optional[str]:
|
||||
override = (self.MULENPAY_IFRAME_EXPECTED_ORIGIN or "").strip()
|
||||
if override:
|
||||
return override
|
||||
|
||||
base_url = (self.MULENPAY_BASE_URL or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
return None
|
||||
|
||||
def is_pal24_enabled(self) -> bool:
|
||||
return (
|
||||
self.PAL24_ENABLED
|
||||
@@ -810,6 +1076,74 @@ class Settings(BaseSettings):
|
||||
and self.PAL24_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def is_platega_enabled(self) -> bool:
|
||||
return (
|
||||
self.PLATEGA_ENABLED
|
||||
and self.PLATEGA_MERCHANT_ID is not None
|
||||
and self.PLATEGA_SECRET is not None
|
||||
)
|
||||
|
||||
def get_platega_return_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_RETURN_URL:
|
||||
return self.PLATEGA_RETURN_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-success"
|
||||
return None
|
||||
|
||||
def get_platega_failed_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_FAILED_URL:
|
||||
return self.PLATEGA_FAILED_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-failed"
|
||||
return None
|
||||
|
||||
def get_platega_active_methods(self) -> List[int]:
|
||||
raw_value = str(self.PLATEGA_ACTIVE_METHODS or "")
|
||||
normalized = raw_value.replace(";", ",")
|
||||
methods: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for part in normalized.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
method_code = int(part)
|
||||
except ValueError:
|
||||
logger.warning("Некорректный код метода Platega: %s", part)
|
||||
continue
|
||||
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
|
||||
methods.append(method_code)
|
||||
seen.add(method_code)
|
||||
|
||||
if not methods:
|
||||
return [2]
|
||||
|
||||
return methods
|
||||
|
||||
@staticmethod
|
||||
def get_platega_method_definitions() -> Dict[int, Dict[str, str]]:
|
||||
return {
|
||||
2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"},
|
||||
10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"},
|
||||
11: {"name": "Банковские карты", "title": "💳 Банковские карты"},
|
||||
12: {"name": "Международные карты", "title": "🌍 Международные карты"},
|
||||
13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"},
|
||||
}
|
||||
|
||||
def get_platega_method_display_name(self, method_code: int) -> str:
|
||||
definitions = self.get_platega_method_definitions()
|
||||
info = definitions.get(method_code)
|
||||
if info and info.get("name"):
|
||||
return info["name"]
|
||||
return f"Метод {method_code}"
|
||||
|
||||
def get_platega_method_display_title(self, method_code: int) -> str:
|
||||
definitions = self.get_platega_method_definitions()
|
||||
info = definitions.get(method_code)
|
||||
if not info:
|
||||
return f"Platega {method_code}"
|
||||
return info.get("title") or info.get("name") or f"Platega {method_code}"
|
||||
|
||||
def is_wata_enabled(self) -> bool:
|
||||
return (
|
||||
self.WATA_ENABLED
|
||||
@@ -817,6 +1151,24 @@ class Settings(BaseSettings):
|
||||
and self.WATA_TERMINAL_PUBLIC_ID is not None
|
||||
)
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
def get_payment_verification_auto_check_interval(self) -> int:
|
||||
try:
|
||||
minutes = int(self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES)
|
||||
except (TypeError, ValueError): # pragma: no cover - защитная проверка конфигурации
|
||||
minutes = 10
|
||||
|
||||
if minutes <= 0:
|
||||
logger.warning(
|
||||
"Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.",
|
||||
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
|
||||
)
|
||||
return 10
|
||||
|
||||
return minutes
|
||||
|
||||
def get_cryptobot_base_url(self) -> str:
|
||||
if self.CRYPTOBOT_TESTNET:
|
||||
return "https://testnet-pay.crypt.bot"
|
||||
@@ -834,6 +1186,26 @@ class Settings(BaseSettings):
|
||||
def get_cryptobot_invoice_expires_seconds(self) -> int:
|
||||
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
|
||||
|
||||
def get_heleket_markup_percent(self) -> float:
|
||||
try:
|
||||
return float(self.HELEKET_MARKUP_PERCENT)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def get_heleket_lifetime(self) -> int:
|
||||
try:
|
||||
value = int(self.HELEKET_INVOICE_LIFETIME)
|
||||
except (TypeError, ValueError):
|
||||
value = 3600
|
||||
return max(300, min(43200, value))
|
||||
|
||||
def get_heleket_callback_url(self) -> Optional[str]:
|
||||
if self.HELEKET_CALLBACK_URL:
|
||||
return self.HELEKET_CALLBACK_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}{self.HELEKET_WEBHOOK_PATH}"
|
||||
return None
|
||||
|
||||
def is_happ_cryptolink_mode(self) -> bool:
|
||||
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
|
||||
|
||||
@@ -847,6 +1219,16 @@ class Settings(BaseSettings):
|
||||
return False
|
||||
return self.HIDE_SUBSCRIPTION_LINK
|
||||
|
||||
def is_contests_enabled(self) -> bool:
|
||||
if getattr(self, "CONTESTS_ENABLED", False):
|
||||
return True
|
||||
# legacy fallback
|
||||
return bool(getattr(self, "REFERRAL_CONTESTS_ENABLED", False))
|
||||
|
||||
def is_referral_contests_enabled(self) -> bool:
|
||||
# kept for backward compatibility
|
||||
return self.is_contests_enabled()
|
||||
|
||||
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
|
||||
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
|
||||
return template or None
|
||||
@@ -878,6 +1260,13 @@ class Settings(BaseSettings):
|
||||
def get_maintenance_check_interval(self) -> int:
|
||||
return self.MAINTENANCE_CHECK_INTERVAL
|
||||
|
||||
def get_maintenance_retry_attempts(self) -> int:
|
||||
try:
|
||||
attempts = int(self.MAINTENANCE_RETRY_ATTEMPTS)
|
||||
except (TypeError, ValueError):
|
||||
attempts = 1
|
||||
return max(1, attempts)
|
||||
|
||||
def is_base_promo_group_period_discount_enabled(self) -> bool:
|
||||
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
|
||||
|
||||
@@ -920,6 +1309,9 @@ class Settings(BaseSettings):
|
||||
def is_maintenance_auto_enable(self) -> bool:
|
||||
return self.MAINTENANCE_AUTO_ENABLE
|
||||
|
||||
def is_maintenance_monitoring_enabled(self) -> bool:
|
||||
return self.MAINTENANCE_MONITORING_ENABLED
|
||||
|
||||
def get_available_subscription_periods(self) -> List[int]:
|
||||
try:
|
||||
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
|
||||
@@ -958,10 +1350,18 @@ class Settings(BaseSettings):
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 90, 180]
|
||||
|
||||
def get_balance_payment_description(self, amount_kopeks: int) -> str:
|
||||
def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: Optional[int] = None) -> str:
|
||||
# Базовое описание
|
||||
description = f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
|
||||
|
||||
# Если передан user_id, добавляем его
|
||||
if telegram_user_id is not None:
|
||||
description += f" (ID {telegram_user_id})"
|
||||
|
||||
# Формируем финальную строку по шаблону
|
||||
return self.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
|
||||
description=description
|
||||
)
|
||||
|
||||
def get_subscription_payment_description(self, period_days: int, amount_kopeks: int) -> str:
|
||||
@@ -983,7 +1383,7 @@ class Settings(BaseSettings):
|
||||
return stars * self.get_stars_rate()
|
||||
|
||||
def rubles_to_stars(self, rubles: float) -> int:
|
||||
return max(1, int(rubles / self.get_stars_rate()))
|
||||
return max(1, math.ceil(rubles / self.get_stars_rate()))
|
||||
|
||||
def get_admin_notifications_chat_id(self) -> Optional[int]:
|
||||
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
|
||||
@@ -1011,34 +1411,7 @@ 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 {
|
||||
"program_enabled": self.is_referral_program_enabled(),
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
@@ -1046,8 +1419,6 @@ class Settings(BaseSettings):
|
||||
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
|
||||
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
|
||||
"referred_user_reward": referred_reward_kopeks,
|
||||
"referred_user_reward_kopeks": referred_reward_kopeks,
|
||||
}
|
||||
|
||||
def is_referral_program_enabled(self) -> bool:
|
||||
@@ -1064,13 +1435,13 @@ class Settings(BaseSettings):
|
||||
packages = []
|
||||
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
|
||||
|
||||
logger.info(f"CONFIG STRING: '{config_str}'")
|
||||
|
||||
logger.debug(f"CONFIG STRING: '{config_str}'")
|
||||
|
||||
if not config_str:
|
||||
logger.info("CONFIG EMPTY, USING FALLBACK")
|
||||
logger.debug("CONFIG EMPTY, USING FALLBACK")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
logger.info("PARSING CONFIG...")
|
||||
|
||||
logger.debug("PARSING CONFIG...")
|
||||
|
||||
for package_config in config_str.split(','):
|
||||
package_config = package_config.strip()
|
||||
@@ -1094,7 +1465,7 @@ class Settings(BaseSettings):
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.info(f"PARSED {len(packages)} packages from config")
|
||||
logger.debug(f"PARSED {len(packages)} packages from config")
|
||||
return packages if packages else self._get_fallback_traffic_packages()
|
||||
|
||||
except Exception as e:
|
||||
@@ -1305,15 +1676,82 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_support_contact_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"contact", "both"}
|
||||
|
||||
|
||||
def get_bot_run_mode(self) -> str:
|
||||
mode = (self.BOT_RUN_MODE or "polling").strip().lower()
|
||||
if mode not in {"polling", "webhook", "both"}:
|
||||
return "polling"
|
||||
return mode
|
||||
|
||||
def get_telegram_webhook_path(self) -> str:
|
||||
raw_path = (self.WEBHOOK_PATH or "/webhook").strip()
|
||||
if not raw_path:
|
||||
raw_path = "/webhook"
|
||||
if not raw_path.startswith("/"):
|
||||
raw_path = "/" + raw_path
|
||||
return raw_path
|
||||
|
||||
def get_webhook_queue_maxsize(self) -> int:
|
||||
try:
|
||||
size = int(self.WEBHOOK_MAX_QUEUE_SIZE)
|
||||
except (TypeError, ValueError):
|
||||
size = 1024
|
||||
return max(1, size)
|
||||
|
||||
def get_webhook_worker_count(self) -> int:
|
||||
try:
|
||||
workers = int(self.WEBHOOK_WORKERS)
|
||||
except (TypeError, ValueError):
|
||||
workers = 1
|
||||
return max(1, workers)
|
||||
|
||||
def get_webhook_enqueue_timeout(self) -> float:
|
||||
try:
|
||||
timeout = float(self.WEBHOOK_ENQUEUE_TIMEOUT)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 0.0
|
||||
return max(0.0, timeout)
|
||||
|
||||
def get_webhook_shutdown_timeout(self) -> float:
|
||||
try:
|
||||
timeout = float(self.WEBHOOK_WORKER_SHUTDOWN_TIMEOUT)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 30.0
|
||||
return max(1.0, timeout)
|
||||
|
||||
def get_telegram_webhook_url(self) -> Optional[str]:
|
||||
base_url = (self.WEBHOOK_URL or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
path = self.get_telegram_webhook_path()
|
||||
return f"{base_url.rstrip('/')}{path}"
|
||||
|
||||
def get_miniapp_static_path(self) -> Path:
|
||||
raw_path = (self.MINIAPP_STATIC_PATH or "miniapp").strip()
|
||||
if not raw_path:
|
||||
raw_path = "miniapp"
|
||||
return Path(raw_path)
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
"extra": "ignore"
|
||||
"extra": "ignore"
|
||||
}
|
||||
|
||||
@field_validator("TIMEZONE")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except Exception as exc: # pragma: no cover - defensive validation
|
||||
raise ValueError(
|
||||
f"Некорректный идентификатор часового пояса: {value}"
|
||||
) from exc
|
||||
return value
|
||||
|
||||
|
||||
settings = Settings()
|
||||
ENV_OVERRIDE_KEYS = set(settings.model_fields_set)
|
||||
|
||||
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
|
||||
14: "PRICE_14_DAYS",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Database package exports."""
|
||||
|
||||
from .database import (
|
||||
DatabaseManager,
|
||||
batch_ops,
|
||||
close_db,
|
||||
db_manager,
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DatabaseManager",
|
||||
"batch_ops",
|
||||
"close_db",
|
||||
"db_manager",
|
||||
"get_db",
|
||||
"get_db_read_only",
|
||||
"get_pool_metrics",
|
||||
"init_db",
|
||||
]
|
||||
@@ -0,0 +1,210 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import and_, delete, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ContestTemplate, ContestRound, ContestAttempt, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Templates
|
||||
async def get_template_by_id(db: AsyncSession, template_id: int) -> Optional[ContestTemplate]:
|
||||
result = await db.execute(
|
||||
select(ContestTemplate).where(ContestTemplate.id == template_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_template_by_slug(db: AsyncSession, slug: str) -> Optional[ContestTemplate]:
|
||||
result = await db.execute(
|
||||
select(ContestTemplate).where(ContestTemplate.slug == slug)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_templates(db: AsyncSession, enabled_only: bool = True) -> List[ContestTemplate]:
|
||||
query = select(ContestTemplate).order_by(ContestTemplate.id)
|
||||
if enabled_only:
|
||||
query = query.where(ContestTemplate.is_enabled.is_(True))
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def upsert_template(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
prize_days: int = 1,
|
||||
max_winners: int = 1,
|
||||
attempts_per_user: int = 1,
|
||||
times_per_day: int = 1,
|
||||
schedule_times: Optional[str] = None,
|
||||
cooldown_hours: int = 24,
|
||||
payload: Optional[dict] = None,
|
||||
is_enabled: Optional[bool] = None,
|
||||
) -> ContestTemplate:
|
||||
template = await get_template_by_slug(db, slug)
|
||||
if not template:
|
||||
template = ContestTemplate(slug=slug)
|
||||
db.add(template)
|
||||
|
||||
template.name = name
|
||||
template.description = description
|
||||
template.prize_days = prize_days
|
||||
template.max_winners = max_winners
|
||||
template.attempts_per_user = attempts_per_user
|
||||
template.times_per_day = times_per_day
|
||||
template.schedule_times = schedule_times
|
||||
template.cooldown_hours = cooldown_hours
|
||||
template.payload = payload or {}
|
||||
if is_enabled is not None:
|
||||
template.is_enabled = is_enabled
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
async def update_template_fields(
|
||||
db: AsyncSession,
|
||||
template: ContestTemplate,
|
||||
**fields: object,
|
||||
) -> ContestTemplate:
|
||||
for key, value in fields.items():
|
||||
if hasattr(template, key):
|
||||
setattr(template, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
# Rounds
|
||||
async def create_round(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
template: ContestTemplate,
|
||||
starts_at: datetime,
|
||||
ends_at: datetime,
|
||||
payload: dict,
|
||||
) -> ContestRound:
|
||||
round_obj = ContestRound(
|
||||
template_id=template.id,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
status="active",
|
||||
payload=payload,
|
||||
max_winners=template.max_winners,
|
||||
attempts_per_user=template.attempts_per_user,
|
||||
)
|
||||
db.add(round_obj)
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
async def get_active_rounds(db: AsyncSession) -> List[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(
|
||||
and_(
|
||||
ContestRound.status == "active",
|
||||
ContestRound.starts_at <= now,
|
||||
ContestRound.ends_at >= now,
|
||||
)
|
||||
)
|
||||
.order_by(ContestRound.starts_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Optional[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(
|
||||
and_(
|
||||
ContestRound.template_id == template_id,
|
||||
ContestRound.status == "active",
|
||||
ContestRound.starts_at <= now,
|
||||
ContestRound.ends_at >= now,
|
||||
)
|
||||
)
|
||||
.order_by(desc(ContestRound.starts_at))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
|
||||
round_obj.status = "finished"
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
|
||||
round_obj.winners_count += 1
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
# Attempts
|
||||
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> Optional[ContestAttempt]:
|
||||
result = await db.execute(
|
||||
select(ContestAttempt).where(
|
||||
and_(
|
||||
ContestAttempt.round_id == round_id,
|
||||
ContestAttempt.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_attempt(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
round_id: int,
|
||||
user_id: int,
|
||||
answer: Optional[str],
|
||||
is_winner: bool,
|
||||
) -> ContestAttempt:
|
||||
attempt = ContestAttempt(
|
||||
round_id=round_id,
|
||||
user_id=user_id,
|
||||
answer=answer,
|
||||
is_winner=is_winner,
|
||||
)
|
||||
db.add(attempt)
|
||||
await db.commit()
|
||||
await db.refresh(attempt)
|
||||
return attempt
|
||||
|
||||
|
||||
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
|
||||
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
|
||||
deleted_count = result.rowcount
|
||||
await db.commit()
|
||||
return deleted_count
|
||||
|
||||
|
||||
async def list_winners(db: AsyncSession, round_id: int) -> Sequence[Tuple[User, ContestAttempt]]:
|
||||
result = await db.execute(
|
||||
select(User, ContestAttempt)
|
||||
.join(ContestAttempt, ContestAttempt.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
ContestAttempt.round_id == round_id,
|
||||
ContestAttempt.is_winner.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.all()
|
||||
@@ -0,0 +1,176 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import HeleketPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_heleket_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
uuid: str,
|
||||
order_id: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
status: str,
|
||||
payer_amount: Optional[str] = None,
|
||||
payer_currency: Optional[str] = None,
|
||||
exchange_rate: Optional[float] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> HeleketPayment:
|
||||
payment = HeleketPayment(
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
status=status,
|
||||
payer_amount=payer_amount,
|
||||
payer_currency=payer_currency,
|
||||
exchange_rate=exchange_rate,
|
||||
discount_percent=discount_percent,
|
||||
payment_url=payment_url,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
|
||||
uuid,
|
||||
order_id,
|
||||
amount,
|
||||
currency,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_heleket_payment_by_uuid(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_order_id(
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_heleket_payment(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
payer_amount: Optional[str] = None,
|
||||
payer_currency: Optional[str] = None,
|
||||
exchange_rate: Optional[float] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[HeleketPayment]:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error("Heleket платеж с uuid=%s не найден", uuid)
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if payer_amount is not None:
|
||||
payment.payer_amount = payer_amount
|
||||
if payer_currency is not None:
|
||||
payment.payer_currency = payer_currency
|
||||
if exchange_rate is not None:
|
||||
payment.exchange_rate = exchange_rate
|
||||
if discount_percent is not None:
|
||||
payment.discount_percent = discount_percent
|
||||
if payment_url is not None:
|
||||
payment.payment_url = payment_url
|
||||
if metadata is not None:
|
||||
existing = dict(payment.metadata_json or {})
|
||||
existing.update(metadata)
|
||||
payment.metadata_json = existing
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
|
||||
uuid,
|
||||
payment.status,
|
||||
payment.payer_amount,
|
||||
payment.payer_currency,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_heleket_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
transaction_id: int,
|
||||
) -> Optional[HeleketPayment]:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Heleket платеж %s связан с транзакцией %s",
|
||||
uuid,
|
||||
transaction_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -90,6 +91,7 @@ async def update_mulenpay_payment_status(
|
||||
paid_at: Optional[datetime] = None,
|
||||
callback_payload: Optional[dict] = None,
|
||||
mulen_payment_id: Optional[int] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> MulenPayPayment:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
@@ -100,6 +102,8 @@ async def update_mulenpay_payment_status(
|
||||
payment.callback_payload = callback_payload
|
||||
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
||||
payment.mulen_payment_id = mulen_payment_id
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -107,6 +111,19 @@ async def update_mulenpay_payment_status(
|
||||
return payment
|
||||
|
||||
|
||||
async def update_mulenpay_payment_metadata(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: MulenPayPayment,
|
||||
metadata: dict,
|
||||
) -> MulenPayPayment:
|
||||
payment.metadata_json = metadata
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_mulenpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -96,6 +96,7 @@ async def update_pal24_payment_status(
|
||||
balance_currency: Optional[str] = None,
|
||||
payer_account: Optional[str] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Payment:
|
||||
update_values: Dict[str, Any] = {
|
||||
"status": status,
|
||||
@@ -121,6 +122,8 @@ async def update_pal24_payment_status(
|
||||
update_values["payer_account"] = payer_account
|
||||
if callback_payload is not None:
|
||||
update_values["callback_payload"] = callback_payload
|
||||
if metadata is not None:
|
||||
update_values["metadata_json"] = metadata
|
||||
|
||||
update_values["last_status"] = status
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""CRUD-операции для платежей Platega."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PlategaPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
payment_method_code: int,
|
||||
correlation_id: str,
|
||||
platega_transaction_id: Optional[str],
|
||||
redirect_url: Optional[str],
|
||||
return_url: Optional[str],
|
||||
failed_url: Optional[str],
|
||||
payload: Optional[str],
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
payment = PlategaPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
payment_method_code=payment_method_code,
|
||||
correlation_id=correlation_id,
|
||||
platega_transaction_id=platega_transaction_id,
|
||||
redirect_url=redirect_url,
|
||||
return_url=return_url,
|
||||
failed_url=failed_url,
|
||||
payload=payload,
|
||||
metadata_json=metadata or {},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
|
||||
payment.id,
|
||||
platega_transaction_id,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_platega_payment_by_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(PlategaPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment)
|
||||
.where(PlategaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_transaction_id(
|
||||
db: AsyncSession, transaction_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.platega_transaction_id == transaction_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_correlation_id(
|
||||
db: AsyncSession, correlation_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.correlation_id == correlation_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
status: Optional[str] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
platega_transaction_id: Optional[str] = None,
|
||||
redirect_url: Optional[str] = None,
|
||||
callback_payload: Optional[dict[str, Any]] = None,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
if platega_transaction_id and not payment.platega_transaction_id:
|
||||
payment.platega_transaction_id = platega_transaction_id
|
||||
if redirect_url is not None:
|
||||
payment.redirect_url = redirect_url
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
if expires_at is not None:
|
||||
payment.expires_at = expires_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_platega_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
transaction_id: int,
|
||||
) -> PlategaPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,297 @@
|
||||
import logging
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Poll,
|
||||
PollAnswer,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_poll(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
description: str | None,
|
||||
reward_enabled: bool,
|
||||
reward_amount_kopeks: int,
|
||||
created_by: int | None,
|
||||
questions: Sequence[dict[str, Iterable[str]]],
|
||||
) -> Poll:
|
||||
poll = Poll(
|
||||
title=title,
|
||||
description=description,
|
||||
reward_enabled=reward_enabled,
|
||||
reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(poll)
|
||||
await db.flush()
|
||||
|
||||
for order, question_data in enumerate(questions, start=1):
|
||||
question_text = question_data.get("text", "").strip()
|
||||
if not question_text:
|
||||
continue
|
||||
|
||||
question = PollQuestion(
|
||||
poll_id=poll.id,
|
||||
text=question_text,
|
||||
order=order,
|
||||
)
|
||||
db.add(question)
|
||||
await db.flush()
|
||||
|
||||
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
|
||||
option_text = option_text.strip()
|
||||
if not option_text:
|
||||
continue
|
||||
option = PollOption(
|
||||
question_id=question.id,
|
||||
text=option_text,
|
||||
order=option_order,
|
||||
)
|
||||
db.add(option)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(
|
||||
poll,
|
||||
attribute_names=["questions"],
|
||||
)
|
||||
return poll
|
||||
|
||||
|
||||
async def list_polls(db: AsyncSession) -> list[Poll]:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
|
||||
)
|
||||
.order_by(Poll.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
|
||||
selectinload(Poll.responses),
|
||||
)
|
||||
.where(Poll.id == poll_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
|
||||
poll = await db.get(Poll, poll_id)
|
||||
if not poll:
|
||||
return False
|
||||
|
||||
await db.delete(poll)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалён опрос %s", poll_id)
|
||||
return True
|
||||
|
||||
|
||||
async def create_poll_response(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
user_id: int,
|
||||
) -> PollResponse:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
response = result.scalar_one_or_none()
|
||||
if response:
|
||||
return response
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(response)
|
||||
await db.commit()
|
||||
await db.refresh(response)
|
||||
return response
|
||||
|
||||
|
||||
async def get_poll_response_by_id(
|
||||
db: AsyncSession,
|
||||
response_id: int,
|
||||
) -> PollResponse | None:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.poll)
|
||||
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
|
||||
selectinload(PollResponse.answers),
|
||||
selectinload(PollResponse.user),
|
||||
)
|
||||
.where(PollResponse.id == response_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_poll_answer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
response_id: int,
|
||||
question_id: int,
|
||||
option_id: int,
|
||||
) -> PollAnswer:
|
||||
result = await db.execute(
|
||||
select(PollAnswer)
|
||||
.where(
|
||||
and_(
|
||||
PollAnswer.response_id == response_id,
|
||||
PollAnswer.question_id == question_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
answer = result.scalar_one_or_none()
|
||||
if answer:
|
||||
answer.option_id = option_id
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
answer = PollAnswer(
|
||||
response_id=response_id,
|
||||
question_id=question_id,
|
||||
option_id=option_id,
|
||||
)
|
||||
db.add(answer)
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
|
||||
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(PollAnswer).where(PollAnswer.response_id == response_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
|
||||
totals_result = await db.execute(
|
||||
select(
|
||||
func.count(PollResponse.id),
|
||||
func.count(PollResponse.completed_at),
|
||||
func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
|
||||
).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total_responses, completed_responses, reward_sum = totals_result.one()
|
||||
|
||||
option_counts_result = await db.execute(
|
||||
select(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
func.count(PollAnswer.id),
|
||||
)
|
||||
.join(PollOption, PollOption.question_id == PollQuestion.id)
|
||||
.outerjoin(
|
||||
PollAnswer,
|
||||
and_(
|
||||
PollAnswer.question_id == PollQuestion.id,
|
||||
PollAnswer.option_id == PollOption.id,
|
||||
),
|
||||
)
|
||||
.where(PollQuestion.poll_id == poll_id)
|
||||
.group_by(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
)
|
||||
.order_by(PollQuestion.order.asc(), PollOption.order.asc())
|
||||
)
|
||||
|
||||
questions_map: dict[int, dict] = {}
|
||||
for (
|
||||
question_id,
|
||||
question_text,
|
||||
question_order,
|
||||
option_id,
|
||||
option_text,
|
||||
option_order,
|
||||
answer_count,
|
||||
) in option_counts_result:
|
||||
question_entry = questions_map.setdefault(
|
||||
question_id,
|
||||
{
|
||||
"id": question_id,
|
||||
"text": question_text,
|
||||
"order": question_order,
|
||||
"options": [],
|
||||
},
|
||||
)
|
||||
question_entry["options"].append(
|
||||
{
|
||||
"id": option_id,
|
||||
"text": option_text,
|
||||
"count": answer_count,
|
||||
}
|
||||
)
|
||||
|
||||
questions = sorted(questions_map.values(), key=lambda item: item["order"])
|
||||
|
||||
return {
|
||||
"total_responses": total_responses,
|
||||
"completed_responses": completed_responses,
|
||||
"reward_sum_kopeks": reward_sum,
|
||||
"questions": questions,
|
||||
}
|
||||
|
||||
|
||||
async def get_poll_responses_with_answers(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[list[PollResponse], int]:
|
||||
total_result = await db.execute(
|
||||
select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
|
||||
if total == 0:
|
||||
return [], 0
|
||||
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.user),
|
||||
selectinload(PollResponse.answers).selectinload(PollAnswer.question),
|
||||
selectinload(PollResponse.answers).selectinload(PollAnswer.option),
|
||||
)
|
||||
.where(PollResponse.poll_id == poll_id)
|
||||
.order_by(PollResponse.sent_at.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
responses = result.scalars().unique().all()
|
||||
return responses, total
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.database.models import PromoGroup, User, UserPromoGroup
|
||||
|
||||
|
||||
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
|
||||
@@ -38,7 +38,7 @@ async def get_promo_groups_with_counts(
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
|
||||
.order_by(PromoGroup.priority.desc(), PromoGroup.name)
|
||||
)
|
||||
|
||||
if offset:
|
||||
@@ -89,6 +89,7 @@ async def create_promo_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
priority: int = 0,
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
@@ -110,6 +111,7 @@ async def create_promo_group(
|
||||
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
priority=max(0, priority),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
@@ -152,6 +154,7 @@ async def update_promo_group(
|
||||
group: PromoGroup,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
priority: Optional[int] = None,
|
||||
server_discount_percent: Optional[int] = None,
|
||||
traffic_discount_percent: Optional[int] = None,
|
||||
device_discount_percent: Optional[int] = None,
|
||||
@@ -162,6 +165,8 @@ async def update_promo_group(
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
if priority is not None:
|
||||
group.priority = max(0, priority)
|
||||
if server_discount_percent is not None:
|
||||
group.server_discount_percent = max(0, min(100, server_discount_percent))
|
||||
if traffic_discount_percent is not None:
|
||||
@@ -228,11 +233,42 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Получаем список пользователей, связанных с удаляемой промогруппой
|
||||
affected_user_ids: Set[int] = set()
|
||||
|
||||
user_ids_result = await db.execute(
|
||||
select(User.id).where(User.promo_group_id == group.id)
|
||||
)
|
||||
affected_user_ids.update(user_ids_result.scalars().all())
|
||||
|
||||
promo_group_links_result = await db.execute(
|
||||
select(UserPromoGroup.user_id).where(UserPromoGroup.promo_group_id == group.id)
|
||||
)
|
||||
affected_user_ids.update(promo_group_links_result.scalars().all())
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
.values(promo_group_id=default_group.id)
|
||||
)
|
||||
|
||||
if affected_user_ids:
|
||||
existing_defaults_result = await db.execute(
|
||||
select(UserPromoGroup.user_id)
|
||||
.where(UserPromoGroup.promo_group_id == default_group.id)
|
||||
.where(UserPromoGroup.user_id.in_(affected_user_ids))
|
||||
)
|
||||
existing_default_user_ids = set(existing_defaults_result.scalars().all())
|
||||
|
||||
for user_id in affected_user_ids - existing_default_user_ids:
|
||||
db.add(
|
||||
UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=default_group.id,
|
||||
assigned_by="system",
|
||||
)
|
||||
)
|
||||
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -13,12 +13,50 @@ logger = logging.getLogger(__name__)
|
||||
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(selectinload(PromoCode.uses))
|
||||
.options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
.where(PromoCode.code == code.upper())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> Optional[PromoCode]:
|
||||
"""
|
||||
Получает промокод по ID с eager loading всех связанных данных.
|
||||
Используется для избежания lazy loading в async контексте.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
.where(PromoCode.id == promo_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def check_promocode_validity(db: AsyncSession, code: str) -> dict:
|
||||
"""
|
||||
Проверяет существование и валидность промокода без активации.
|
||||
Возвращает словарь с информацией о промокоде.
|
||||
"""
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
|
||||
if not promocode:
|
||||
return {"valid": False, "error": "not_found", "promocode": None}
|
||||
|
||||
if not promocode.is_valid:
|
||||
if promocode.current_uses >= promocode.max_uses:
|
||||
return {"valid": False, "error": "used", "promocode": None}
|
||||
else:
|
||||
return {"valid": False, "error": "expired", "promocode": None}
|
||||
|
||||
return {"valid": True, "error": None, "promocode": promocode}
|
||||
|
||||
|
||||
async def create_promocode(
|
||||
db: AsyncSession,
|
||||
code: str,
|
||||
@@ -27,7 +65,8 @@ async def create_promocode(
|
||||
subscription_days: int = 0,
|
||||
max_uses: int = 1,
|
||||
valid_until: Optional[datetime] = None,
|
||||
created_by: Optional[int] = None
|
||||
created_by: Optional[int] = None,
|
||||
promo_group_id: Optional[int] = None
|
||||
) -> PromoCode:
|
||||
|
||||
promocode = PromoCode(
|
||||
@@ -37,14 +76,18 @@ async def create_promocode(
|
||||
subscription_days=subscription_days,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=created_by
|
||||
created_by=created_by,
|
||||
promo_group_id=promo_group_id
|
||||
)
|
||||
|
||||
db.add(promocode)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
|
||||
if promo_group_id:
|
||||
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
|
||||
else:
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
return promocode
|
||||
|
||||
|
||||
@@ -53,9 +96,9 @@ async def use_promocode(
|
||||
promocode_id: int,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
|
||||
|
||||
try:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
return False
|
||||
|
||||
@@ -143,14 +186,17 @@ async def get_promocodes_list(
|
||||
limit: int = 50,
|
||||
is_active: Optional[bool] = None
|
||||
) -> List[PromoCode]:
|
||||
|
||||
query = select(PromoCode).options(selectinload(PromoCode.uses))
|
||||
|
||||
|
||||
query = select(PromoCode).options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(PromoCode.is_active == is_active)
|
||||
|
||||
|
||||
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import logging
|
||||
from datetime import datetime, date, time, timezone
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, aliased
|
||||
|
||||
from app.database.models import (
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
User,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_referral_contest(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
description: Optional[str],
|
||||
prize_text: Optional[str],
|
||||
contest_type: str,
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
daily_summary_time: time,
|
||||
daily_summary_times: Optional[str] = None,
|
||||
timezone_name: str,
|
||||
created_by: Optional[int] = None,
|
||||
) -> ReferralContest:
|
||||
contest = ReferralContest(
|
||||
title=title,
|
||||
description=description,
|
||||
prize_text=prize_text,
|
||||
contest_type=contest_type,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
daily_summary_time=daily_summary_time,
|
||||
daily_summary_times=daily_summary_times,
|
||||
timezone=timezone_name or "UTC",
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(contest)
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def list_referral_contests(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
contest_type: Optional[str] = None,
|
||||
) -> List[ReferralContest]:
|
||||
query = (
|
||||
select(ReferralContest)
|
||||
.options(selectinload(ReferralContest.creator))
|
||||
.order_by(desc(ReferralContest.start_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if contest_type:
|
||||
query = query.where(ReferralContest.contest_type == contest_type)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_referral_contests_count(db: AsyncSession, contest_type: Optional[str] = None) -> int:
|
||||
query = select(func.count(ReferralContest.id))
|
||||
if contest_type:
|
||||
query = query.where(ReferralContest.contest_type == contest_type)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_referral_contest(db: AsyncSession, contest_id: int) -> Optional[ReferralContest]:
|
||||
result = await db.execute(
|
||||
select(ReferralContest)
|
||||
.options(
|
||||
selectinload(ReferralContest.creator),
|
||||
selectinload(ReferralContest.events),
|
||||
)
|
||||
.where(ReferralContest.id == contest_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_referral_contest(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
**fields: object,
|
||||
) -> ReferralContest:
|
||||
for key, value in fields.items():
|
||||
if hasattr(contest, key):
|
||||
setattr(contest, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def toggle_referral_contest(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
is_active: bool,
|
||||
) -> ReferralContest:
|
||||
contest.is_active = is_active
|
||||
return await update_referral_contest(db, contest)
|
||||
|
||||
|
||||
async def get_contests_for_events(
|
||||
db: AsyncSession,
|
||||
now_utc: datetime,
|
||||
*,
|
||||
contest_types: Optional[List[str]] = None,
|
||||
) -> List[ReferralContest]:
|
||||
query = select(ReferralContest).where(
|
||||
and_(
|
||||
ReferralContest.is_active.is_(True),
|
||||
ReferralContest.start_at <= now_utc,
|
||||
ReferralContest.end_at >= now_utc,
|
||||
)
|
||||
)
|
||||
if contest_types:
|
||||
query = query.where(ReferralContest.contest_type.in_(contest_types))
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_contests_for_summaries(db: AsyncSession) -> List[ReferralContest]:
|
||||
result = await db.execute(
|
||||
select(ReferralContest).where(ReferralContest.is_active.is_(True))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def add_contest_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
contest_id: int,
|
||||
referrer_id: int,
|
||||
referral_id: int,
|
||||
amount_kopeks: int = 0,
|
||||
event_type: str = "subscription_purchase",
|
||||
) -> Optional[ReferralContestEvent]:
|
||||
existing = await db.execute(
|
||||
select(ReferralContestEvent).where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.referral_id == referral_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return None
|
||||
|
||||
event = ReferralContestEvent(
|
||||
contest_id=contest_id,
|
||||
referrer_id=referrer_id,
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
event_type=event_type,
|
||||
occurred_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
async def get_contest_leaderboard(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
) -> Sequence[Tuple[User, int, int]]:
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return []
|
||||
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
func.count(ReferralContestEvent.id).label("referral_count"),
|
||||
func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label("total_amount"),
|
||||
)
|
||||
.join(User, User.id == ReferralContestEvent.referrer_id)
|
||||
.where(ReferralContestEvent.contest_id == contest_id)
|
||||
.group_by(User.id)
|
||||
.order_by(desc("referral_count"), desc("total_amount"), User.id)
|
||||
)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
result = await db.execute(query)
|
||||
leaderboard = result.all()
|
||||
|
||||
return leaderboard
|
||||
|
||||
|
||||
async def get_contest_participants(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> Sequence[Tuple[User, int]]:
|
||||
result = await db.execute(
|
||||
select(User, func.count(ReferralContestEvent.id).label("referral_count"))
|
||||
.join(User, User.id == ReferralContestEvent.referrer_id)
|
||||
.where(ReferralContestEvent.contest_id == contest_id)
|
||||
.group_by(User.id)
|
||||
)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_referrer_score(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
referrer_id: int,
|
||||
*,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> int:
|
||||
query = select(func.count(ReferralContestEvent.id)).where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.referrer_id == referrer_id,
|
||||
)
|
||||
)
|
||||
if start:
|
||||
query = query.where(ReferralContestEvent.occurred_at >= start)
|
||||
if end:
|
||||
query = query.where(ReferralContestEvent.occurred_at < end)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_contest_events_count(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
*,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> int:
|
||||
query = select(func.count(ReferralContestEvent.id)).where(
|
||||
ReferralContestEvent.contest_id == contest_id
|
||||
)
|
||||
if start:
|
||||
query = query.where(ReferralContestEvent.occurred_at >= start)
|
||||
if end:
|
||||
query = query.where(ReferralContestEvent.occurred_at < end)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_contest_events(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> List[ReferralContestEvent]:
|
||||
result = await db.execute(
|
||||
select(ReferralContestEvent).where(ReferralContestEvent.contest_id == contest_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def mark_daily_summary_sent(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
summary_date: date,
|
||||
summary_dt_utc: Optional[datetime] = None,
|
||||
) -> ReferralContest:
|
||||
contest.last_daily_summary_date = summary_date
|
||||
if summary_dt_utc:
|
||||
contest.last_daily_summary_at = summary_dt_utc
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def mark_final_summary_sent(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
) -> ReferralContest:
|
||||
contest.final_summary_sent = True
|
||||
contest.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def delete_referral_contest(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
) -> None:
|
||||
await db.delete(contest)
|
||||
await db.commit()
|
||||
@@ -47,6 +47,7 @@ async def create_server_squad(
|
||||
max_users: int = None,
|
||||
is_available: bool = True,
|
||||
is_trial_eligible: bool = False,
|
||||
sort_order: int = 0,
|
||||
promo_group_ids: Optional[Iterable[int]] = None,
|
||||
) -> ServerSquad:
|
||||
|
||||
@@ -80,6 +81,7 @@ async def create_server_squad(
|
||||
max_users=max_users,
|
||||
is_available=is_available,
|
||||
is_trial_eligible=is_trial_eligible,
|
||||
sort_order=sort_order,
|
||||
allowed_promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
@@ -167,6 +169,58 @@ async def get_available_server_squads(
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
"""Возвращает список активных серверов, доступных для подключения."""
|
||||
|
||||
squads = await get_available_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return []
|
||||
|
||||
eligible: List[ServerSquad] = []
|
||||
|
||||
for squad in squads:
|
||||
max_users = squad.max_users
|
||||
current_users = squad.current_users or 0
|
||||
|
||||
if max_users is not None and current_users >= max_users:
|
||||
continue
|
||||
|
||||
eligible.append(squad)
|
||||
|
||||
if eligible:
|
||||
return eligible
|
||||
|
||||
return squads
|
||||
|
||||
|
||||
async def choose_random_active_server_squad(
|
||||
db: AsyncSession,
|
||||
) -> Optional[ServerSquad]:
|
||||
"""Возвращает случайный активный сервер."""
|
||||
|
||||
squads = await get_active_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return None
|
||||
|
||||
return random.choice(squads)
|
||||
|
||||
|
||||
async def get_random_active_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Возвращает UUID случайного активного сервера или запасной UUID."""
|
||||
|
||||
squad = await choose_random_active_server_squad(db)
|
||||
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
|
||||
|
||||
async def update_server_squad_promo_groups(
|
||||
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
|
||||
) -> Optional[ServerSquad]:
|
||||
@@ -208,8 +262,15 @@ async def update_server_squad(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
valid_fields = {
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
|
||||
"display_name",
|
||||
"original_name",
|
||||
"country_code",
|
||||
"price_kopeks",
|
||||
"description",
|
||||
"max_users",
|
||||
"is_available",
|
||||
"sort_order",
|
||||
"is_trial_eligible",
|
||||
}
|
||||
|
||||
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
|
||||
@@ -454,7 +515,6 @@ async def choose_random_trial_server_squad(
|
||||
|
||||
async def get_random_trial_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
|
||||
squad = await choose_random_trial_server_squad(db)
|
||||
@@ -462,7 +522,7 @@ async def get_random_trial_squad_uuid(
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
return None
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Iterable, Optional, List, Tuple
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy import select, and_, func, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -11,10 +11,12 @@ from app.database.models import (
|
||||
User,
|
||||
SubscriptionServer,
|
||||
PromoGroup,
|
||||
UserPromoGroup,
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,21 +43,19 @@ async def create_trial_subscription(
|
||||
user_id: int,
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: int = None,
|
||||
device_limit: Optional[int] = None,
|
||||
squad_uuid: str = None
|
||||
) -> 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
|
||||
if device_limit is None:
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
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,
|
||||
)
|
||||
squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
|
||||
if squad_uuid:
|
||||
logger.debug(
|
||||
@@ -69,7 +69,6 @@ async def create_trial_subscription(
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
squad_uuid = settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
@@ -126,13 +125,16 @@ async def create_paid_subscription(
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
device_limit: Optional[int] = None,
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -184,16 +186,118 @@ async def create_paid_subscription(
|
||||
return subscription
|
||||
|
||||
|
||||
async def replace_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
*,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int,
|
||||
device_limit: int,
|
||||
connected_squads: List[str],
|
||||
is_trial: bool,
|
||||
autopay_enabled: Optional[bool] = None,
|
||||
autopay_days_before: Optional[int] = None,
|
||||
update_server_counters: bool = False,
|
||||
) -> Subscription:
|
||||
"""Перезаписывает параметры существующей подписки пользователя."""
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
old_squads = set(subscription.connected_squads or [])
|
||||
new_squads = set(connected_squads or [])
|
||||
|
||||
new_autopay_enabled = (
|
||||
subscription.autopay_enabled
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
)
|
||||
new_autopay_days_before = (
|
||||
subscription.autopay_days_before
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
)
|
||||
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.is_trial = is_trial
|
||||
subscription.start_date = current_time
|
||||
subscription.end_date = current_time + timedelta(days=duration_days)
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
subscription.traffic_used_gb = 0.0
|
||||
subscription.device_limit = device_limit
|
||||
subscription.connected_squads = list(new_squads)
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.autopay_enabled = new_autopay_enabled
|
||||
subscription.autopay_days_before = new_autopay_days_before
|
||||
subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
if update_server_counters:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
add_user_to_servers,
|
||||
get_server_ids_by_uuids,
|
||||
remove_user_from_servers,
|
||||
)
|
||||
|
||||
squads_to_remove = old_squads - new_squads
|
||||
squads_to_add = new_squads - old_squads
|
||||
|
||||
if squads_to_remove:
|
||||
server_ids = await get_server_ids_by_uuids(db, list(squads_to_remove))
|
||||
if server_ids:
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
|
||||
if squads_to_add:
|
||||
server_ids = await get_server_ids_by_uuids(db, list(squads_to_add))
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, sorted(server_ids))
|
||||
|
||||
logger.info(
|
||||
"♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s",
|
||||
subscription.id,
|
||||
len(squads_to_remove),
|
||||
len(squads_to_add),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def extend_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
days: int
|
||||
) -> Subscription:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
|
||||
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
|
||||
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
|
||||
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
|
||||
bonus_days = 0
|
||||
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
|
||||
# Вычисляем остаток триала
|
||||
if subscription.end_date and subscription.end_date > current_time:
|
||||
remaining = subscription.end_date - current_time
|
||||
if remaining.total_seconds() > 0:
|
||||
bonus_days = max(0, remaining.days)
|
||||
logger.info(
|
||||
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
|
||||
bonus_days,
|
||||
subscription.id,
|
||||
)
|
||||
|
||||
# Применяем продление с учетом бонусных дней
|
||||
total_days = days + bonus_days
|
||||
|
||||
if days < 0:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(
|
||||
@@ -202,27 +306,15 @@ async def extend_subscription(
|
||||
subscription.end_date,
|
||||
)
|
||||
elif subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
|
||||
subscription.end_date = subscription.end_date + timedelta(days=total_days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
subscription.end_date = current_time + timedelta(days=total_days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
|
||||
|
||||
if subscription.is_trial:
|
||||
start_date = subscription.start_date or current_time
|
||||
total_duration = subscription.end_date - start_date
|
||||
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
|
||||
|
||||
if total_duration > max_trial_duration:
|
||||
subscription.is_trial = False
|
||||
logger.info(
|
||||
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
|
||||
", итоговая длительность: %s дней",
|
||||
subscription.id,
|
||||
total_duration.days,
|
||||
)
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
# УДАЛЕНО: Автоматическая конвертация триала по длительности
|
||||
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
|
||||
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
|
||||
|
||||
# Логируем статус подписки перед проверкой
|
||||
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
|
||||
@@ -584,10 +676,113 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
"purchased_today": purchased_today,
|
||||
"purchased_week": purchased_week,
|
||||
"purchased_month": purchased_month,
|
||||
"trial_to_paid_conversion": trial_to_paid_conversion,
|
||||
"renewals_count": renewals_count
|
||||
"trial_to_paid_conversion": trial_to_paid_conversion,
|
||||
"renewals_count": renewals_count
|
||||
}
|
||||
|
||||
|
||||
async def get_trial_statistics(db: AsyncSession) -> dict:
|
||||
now = datetime.utcnow()
|
||||
|
||||
total_trials_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True))
|
||||
)
|
||||
total_trials = total_trials_result.scalar() or 0
|
||||
|
||||
active_trials_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.end_date > now,
|
||||
Subscription.status.in_(
|
||||
[SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value]
|
||||
),
|
||||
)
|
||||
)
|
||||
active_trials = active_trials_result.scalar() or 0
|
||||
|
||||
resettable_trials_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.where(
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.end_date <= now,
|
||||
User.has_had_paid_subscription.is_(False),
|
||||
)
|
||||
)
|
||||
resettable_trials = resettable_trials_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"used_trials": total_trials,
|
||||
"active_trials": active_trials,
|
||||
"resettable_trials": resettable_trials,
|
||||
}
|
||||
|
||||
|
||||
async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.subscription_servers),
|
||||
)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.where(
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.end_date <= now,
|
||||
User.has_had_paid_subscription.is_(False),
|
||||
)
|
||||
)
|
||||
|
||||
subscriptions = result.scalars().unique().all()
|
||||
if not subscriptions:
|
||||
return 0
|
||||
|
||||
reset_count = len(subscriptions)
|
||||
for subscription in subscriptions:
|
||||
try:
|
||||
await decrement_subscription_server_counts(
|
||||
db,
|
||||
subscription,
|
||||
subscription_servers=subscription.subscription_servers,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Не удалось обновить счётчики серверов при сбросе триала %s: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
subscription_ids = [subscription.id for subscription in subscriptions]
|
||||
|
||||
if subscription_ids:
|
||||
try:
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id.in_(subscription_ids)
|
||||
)
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Ошибка удаления серверных связей триалов %s: %s",
|
||||
subscription_ids,
|
||||
error,
|
||||
)
|
||||
raise
|
||||
|
||||
await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids)))
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
await db.rollback()
|
||||
logger.error("Ошибка сохранения сброса триалов: %s", error)
|
||||
raise
|
||||
|
||||
logger.info("♻️ Сброшено триальных подписок: %s", reset_count)
|
||||
return reset_count
|
||||
|
||||
async def update_subscription_usage(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
@@ -793,8 +988,8 @@ async def calculate_subscription_total_cost(
|
||||
]
|
||||
}
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
logger.debug(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.debug(f" Базовый период: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
message = (
|
||||
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽"
|
||||
@@ -803,7 +998,7 @@ async def calculate_subscription_total_cost(
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽"
|
||||
@@ -812,7 +1007,7 @@ async def calculate_subscription_total_cost(
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.debug(message)
|
||||
if total_devices_price > 0:
|
||||
message = (
|
||||
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽"
|
||||
@@ -821,8 +1016,8 @@ async def calculate_subscription_total_cost(
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
logger.debug(message)
|
||||
logger.debug(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
|
||||
@@ -910,7 +1105,7 @@ async def get_subscription_renewal_cost(
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
@@ -1127,7 +1322,13 @@ async def check_and_update_subscription_status(
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
|
||||
logger.info(
|
||||
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
format_local_datetime(subscription.end_date),
|
||||
format_local_datetime(current_time),
|
||||
)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
@@ -1144,6 +1345,63 @@ async def check_and_update_subscription_status(
|
||||
|
||||
return subscription
|
||||
|
||||
async def create_subscription_no_commit(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: str = "trial",
|
||||
is_trial: bool = True,
|
||||
end_date: datetime = None,
|
||||
traffic_limit_gb: int = 10,
|
||||
traffic_used_gb: float = 0.0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = "",
|
||||
autopay_enabled: Optional[bool] = None,
|
||||
autopay_days_before: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
"""
|
||||
Создает подписку без немедленного коммита для пакетной обработки
|
||||
"""
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.utcnow() + timedelta(days=3)
|
||||
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
is_trial=is_trial,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
autopay_enabled=(
|
||||
settings.is_autopay_enabled_by_default()
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
),
|
||||
autopay_days_before=(
|
||||
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
),
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
|
||||
logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
@@ -1309,12 +1567,10 @@ async def activate_pending_subscription(
|
||||
|
||||
# Если указан период, обновляем дату окончания
|
||||
if period_days is not None:
|
||||
if pending_subscription.end_date <= current_time:
|
||||
# Если текущая дата окончания уже прошла, устанавливаем новую
|
||||
pending_subscription.end_date = current_time + timedelta(days=period_days)
|
||||
else:
|
||||
# Если дата окончания в будущем, продляем её
|
||||
pending_subscription.end_date = pending_subscription.end_date + timedelta(days=period_days)
|
||||
effective_start = pending_subscription.start_date or current_time
|
||||
if effective_start < current_time:
|
||||
effective_start = current_time
|
||||
pending_subscription.end_date = effective_start + timedelta(days=period_days)
|
||||
|
||||
# Обновляем дату начала, если она не установлена или в прошлом
|
||||
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SubscriptionEvent
|
||||
|
||||
|
||||
async def create_subscription_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
event_type: str,
|
||||
subscription_id: Optional[int] = None,
|
||||
transaction_id: Optional[int] = None,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
currency: Optional[str] = None,
|
||||
message: Optional[str] = None,
|
||||
occurred_at: Optional[datetime] = None,
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> SubscriptionEvent:
|
||||
event = SubscriptionEvent(
|
||||
user_id=user_id,
|
||||
event_type=event_type,
|
||||
subscription_id=subscription_id,
|
||||
transaction_id=transaction_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
message=message,
|
||||
occurred_at=occurred_at or datetime.utcnow(),
|
||||
extra=extra or None,
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
async def list_subscription_events(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
event_types: Optional[Iterable[str]] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> Tuple[list[SubscriptionEvent], int]:
|
||||
base_query = select(SubscriptionEvent)
|
||||
filters = []
|
||||
|
||||
if event_types:
|
||||
filters.append(SubscriptionEvent.event_type.in_(set(event_types)))
|
||||
if user_id:
|
||||
filters.append(SubscriptionEvent.user_id == user_id)
|
||||
|
||||
if filters:
|
||||
base_query = base_query.where(and_(*filters))
|
||||
|
||||
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||
total = await db.scalar(total_query) or 0
|
||||
|
||||
result = await db.execute(
|
||||
base_query.options(selectinload(SubscriptionEvent.user))
|
||||
.order_by(SubscriptionEvent.occurred_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
return result.scalars().all(), int(total)
|
||||
@@ -333,18 +333,42 @@ class TicketCRUD:
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
action: Optional[str] = None,
|
||||
) -> List[SupportAuditLog]:
|
||||
from sqlalchemy import select, desc
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
|
||||
)
|
||||
|
||||
query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at))
|
||||
|
||||
if action:
|
||||
query = query.where(SupportAuditLog.action == action)
|
||||
|
||||
result = await db.execute(query.offset(offset).limit(limit))
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_support_audit(db: AsyncSession) -> int:
|
||||
async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int:
|
||||
from sqlalchemy import select, func
|
||||
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
|
||||
|
||||
query = select(func.count()).select_from(SupportAuditLog)
|
||||
|
||||
if action:
|
||||
query = query.where(SupportAuditLog.action == action)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def list_support_audit_actions(db: AsyncSession) -> List[str]:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog.action)
|
||||
.where(SupportAuditLog.action.isnot(None))
|
||||
.distinct()
|
||||
.order_by(SupportAuditLog.action)
|
||||
)
|
||||
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
async def get_open_tickets_count(db: AsyncSession) -> int:
|
||||
|
||||
@@ -50,6 +50,21 @@ async def create_transaction(
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT:
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
await referral_contest_service.on_subscription_payment(
|
||||
db,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось записать событие конкурса для пользователя %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
+203
-27
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict
|
||||
from sqlalchemy import select, and_, or_, func, case, nullslast, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database.models import (
|
||||
@@ -15,6 +15,7 @@ from app.database.models import (
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
UserPromoGroup,
|
||||
PaymentMethod,
|
||||
TransactionType,
|
||||
)
|
||||
@@ -38,14 +39,16 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
@@ -56,26 +59,83 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
|
||||
if not username:
|
||||
return None
|
||||
|
||||
normalized = username.lower()
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(func.lower(User.username) == normalized)
|
||||
)
|
||||
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.promo_group))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.remnawave_uuid == remnawave_uuid)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
@@ -122,6 +182,57 @@ async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
|
||||
return default_group
|
||||
|
||||
|
||||
async def create_user_no_commit(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
username: str = None,
|
||||
first_name: str = None,
|
||||
last_name: str = None,
|
||||
language: str = "ru",
|
||||
referred_by_id: int = None,
|
||||
referral_code: str = None
|
||||
) -> User:
|
||||
"""
|
||||
Создает пользователя без немедленного коммита для пакетной обработки
|
||||
"""
|
||||
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
|
||||
safe_first = sanitize_telegram_name(first_name)
|
||||
safe_last = sanitize_telegram_name(last_name)
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
|
||||
# Обязательно выполняем flush, чтобы получить присвоенный первичный ключ
|
||||
await db.flush()
|
||||
|
||||
# Сохраняем ссылку на группу, чтобы дальнейшие операции могли её использовать
|
||||
user.promo_group = default_group
|
||||
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
logger.info(
|
||||
f"✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
@@ -219,7 +330,8 @@ async def add_user_balance(
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
create_transaction: bool = True,
|
||||
bot = None
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
bot = None
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
@@ -228,12 +340,11 @@ async def add_user_balance(
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.models import TransactionType
|
||||
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
@@ -253,9 +364,10 @@ async def add_user_balance(
|
||||
|
||||
async def add_user_balance_by_id(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
description: str = "Пополнение баланса",
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
@@ -263,7 +375,13 @@ async def add_user_balance_by_id(
|
||||
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
|
||||
return False
|
||||
|
||||
return await add_user_balance(db, user, amount_kopeks, description)
|
||||
return await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
description,
|
||||
transaction_type=transaction_type,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
|
||||
@@ -280,11 +398,11 @@ async def subtract_user_balance(
|
||||
*,
|
||||
consume_promo_offer: bool = False,
|
||||
) -> bool:
|
||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
|
||||
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||
logger.error(f" 📝 Описание: {description}")
|
||||
logger.info(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.info(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
logger.info(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
|
||||
logger.info(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||
logger.info(f" 📝 Описание: {description}")
|
||||
|
||||
log_context: Optional[Dict[str, object]] = None
|
||||
if consume_promo_offer:
|
||||
@@ -493,7 +611,11 @@ async def get_users_list(
|
||||
order_by_purchase_count: bool = False
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
query = select(User).options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
@@ -507,7 +629,14 @@ async def get_users_list(
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
try:
|
||||
search_int = int(search)
|
||||
# Добавляем условие поиска по telegram_id, который является BigInteger
|
||||
# и может содержать большие значения, в отличие от User.id (INTEGER)
|
||||
conditions.append(User.telegram_id == search_int)
|
||||
except ValueError:
|
||||
# Если не удалось преобразовать в int, просто ищем по текстовым полям
|
||||
pass
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -581,7 +710,15 @@ async def get_users_list(
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_users_count(
|
||||
@@ -604,7 +741,14 @@ async def get_users_count(
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
try:
|
||||
search_int = int(search)
|
||||
# Добавляем условие поиска по telegram_id, который является BigInteger
|
||||
# и может содержать большие значения, в отличие от User.id (INTEGER)
|
||||
conditions.append(User.telegram_id == search_int)
|
||||
except ValueError:
|
||||
# Если не удалось преобразовать в int, просто ищем по текстовым полям
|
||||
pass
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -673,12 +817,22 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
|
||||
@@ -686,7 +840,11 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
|
||||
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
|
||||
@@ -731,7 +889,15 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
|
||||
return []
|
||||
|
||||
result = await db.execute(query.order_by(User.id))
|
||||
return result.scalars().unique().all()
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
@@ -741,6 +907,8 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(
|
||||
@@ -750,7 +918,15 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, user: User) -> bool:
|
||||
|
||||
@@ -5,7 +5,8 @@ from typing import Optional, List
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import UserMessage
|
||||
from app.database.models import User, UserMessage
|
||||
from app.utils.validators import sanitize_html, validate_html_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,15 +14,25 @@ logger = logging.getLogger(__name__)
|
||||
async def create_user_message(
|
||||
db: AsyncSession,
|
||||
message_text: str,
|
||||
created_by: int,
|
||||
created_by: Optional[int] = None,
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0
|
||||
) -> UserMessage:
|
||||
is_valid, error_message = validate_html_tags(message_text)
|
||||
if not is_valid:
|
||||
raise ValueError(error_message)
|
||||
|
||||
resolved_creator = created_by
|
||||
|
||||
if created_by is not None:
|
||||
result = await db.execute(select(User.id).where(User.id == created_by))
|
||||
resolved_creator = result.scalar_one_or_none()
|
||||
|
||||
message = UserMessage(
|
||||
message_text=message_text,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
created_by=created_by
|
||||
created_by=resolved_creator,
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
@@ -55,25 +66,33 @@ async def get_random_active_message(db: AsyncSession) -> Optional[str]:
|
||||
return None
|
||||
|
||||
random_message = random.choice(active_messages)
|
||||
return random_message.message_text
|
||||
return sanitize_html(random_message.message_text)
|
||||
|
||||
|
||||
async def get_all_user_messages(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50
|
||||
limit: int = 50,
|
||||
include_inactive: bool = True,
|
||||
) -> List[UserMessage]:
|
||||
query = select(UserMessage).order_by(UserMessage.created_at.desc())
|
||||
if not include_inactive:
|
||||
query = query.where(UserMessage.is_active == True)
|
||||
|
||||
result = await db.execute(
|
||||
select(UserMessage)
|
||||
.order_by(UserMessage.created_at.desc())
|
||||
query
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_messages_count(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(UserMessage.id)))
|
||||
async def get_user_messages_count(db: AsyncSession, include_inactive: bool = True) -> int:
|
||||
query = select(func.count(UserMessage.id))
|
||||
if not include_inactive:
|
||||
query = query.where(UserMessage.is_active == True)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
@@ -88,8 +107,11 @@ async def update_user_message(
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
|
||||
if message_text is not None:
|
||||
is_valid, error_message = validate_html_tags(message_text)
|
||||
if not is_valid:
|
||||
raise ValueError(error_message)
|
||||
message.message_text = message_text
|
||||
|
||||
if is_active is not None:
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, and_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import UserPromoGroup, PromoGroup, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _sync_user_primary_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Синхронизирует колонку users.promo_group_id с primary промогруппой."""
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup.promo_group_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
|
||||
first = result.first()
|
||||
new_primary_id = first[0] if first else None
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
if user.promo_group_id != new_primary_id:
|
||||
user.promo_group_id = new_primary_id
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка синхронизации primary промогруппы пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
async def sync_user_primary_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Публичная обертка для синхронизации primary промогруппы пользователя."""
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
|
||||
async def add_user_to_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int,
|
||||
assigned_by: str = "admin"
|
||||
) -> Optional[UserPromoGroup]:
|
||||
"""
|
||||
Добавляет пользователю промогруппу.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
|
||||
|
||||
Returns:
|
||||
UserPromoGroup или None если уже существует
|
||||
"""
|
||||
try:
|
||||
# Проверяем существование связи
|
||||
existing = await has_user_promo_group(db, user_id, promo_group_id)
|
||||
if existing:
|
||||
logger.info(f"Пользователь {user_id} уже имеет промогруппу {promo_group_id}")
|
||||
return None
|
||||
|
||||
# Создаем новую связь
|
||||
user_promo_group = UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=promo_group_id,
|
||||
assigned_by=assigned_by,
|
||||
)
|
||||
db.add(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
|
||||
logger.info(f"Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})")
|
||||
return user_promo_group
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления промогруппы пользователю: {error}")
|
||||
await db.rollback()
|
||||
return None
|
||||
|
||||
|
||||
async def remove_user_from_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Удаляет промогруппу у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
|
||||
Returns:
|
||||
True если удалено, False если связи не было
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(
|
||||
UserPromoGroup.user_id == user_id,
|
||||
UserPromoGroup.promo_group_id == promo_group_id
|
||||
)
|
||||
)
|
||||
)
|
||||
user_promo_group = result.scalar_one_or_none()
|
||||
|
||||
if not user_promo_group:
|
||||
logger.warning(f"Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена")
|
||||
return False
|
||||
|
||||
await db.delete(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"У пользователя {user_id} удалена промогруппа {promo_group_id}")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка удаления промогруппы у пользователя: {error}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> List[UserPromoGroup]:
|
||||
"""
|
||||
Получает все промогруппы пользователя, отсортированные по приоритету.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup)
|
||||
.options(selectinload(UserPromoGroup.promo_group))
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения промогрупп пользователя {user_id}: {error}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_primary_user_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[PromoGroup]:
|
||||
"""
|
||||
Получает промогруппу пользователя с максимальным приоритетом.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
PromoGroup с максимальным приоритетом или None
|
||||
"""
|
||||
try:
|
||||
user_promo_groups = await get_user_promo_groups(db, user_id)
|
||||
|
||||
if not user_promo_groups:
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения primary промогруппы пользователя {user_id}: {error}")
|
||||
return None
|
||||
|
||||
|
||||
async def has_user_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет наличие промогруппы у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
|
||||
Returns:
|
||||
True если пользователь уже имеет эту промогруппу
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(
|
||||
UserPromoGroup.user_id == user_id,
|
||||
UserPromoGroup.promo_group_id == promo_group_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка проверки промогруппы пользователя: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def count_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> int:
|
||||
"""
|
||||
Подсчитывает количество промогрупп у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Количество промогрупп
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
return len(list(result.scalars().all()))
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка подсчета промогрупп пользователя: {error}")
|
||||
return 0
|
||||
|
||||
|
||||
async def replace_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_ids: List[int],
|
||||
assigned_by: str = "admin"
|
||||
) -> bool:
|
||||
"""
|
||||
Заменяет все промогруппы пользователя на новый список.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_ids: Список ID промогрупп
|
||||
assigned_by: Кто назначил
|
||||
|
||||
Returns:
|
||||
True если успешно
|
||||
"""
|
||||
try:
|
||||
# Удаляем все текущие промогруппы
|
||||
await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
for upg in result.scalars().all():
|
||||
await db.delete(upg)
|
||||
|
||||
# Добавляем новые
|
||||
for promo_group_id in promo_group_ids:
|
||||
user_promo_group = UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=promo_group_id,
|
||||
assigned_by=assigned_by
|
||||
)
|
||||
db.add(user_promo_group)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Промогруппы пользователя {user_id} заменены на {promo_group_ids}")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка замены промогрупп пользователя: {error}")
|
||||
await db.rollback()
|
||||
return False
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select, update, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WelcomeText
|
||||
from app.database.models import User, WelcomeText
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +45,37 @@ async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
|
||||
'id': None
|
||||
}
|
||||
|
||||
|
||||
async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]:
|
||||
result = await db.execute(
|
||||
select(WelcomeText).where(WelcomeText.id == welcome_text_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_welcome_texts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = True,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
):
|
||||
query = select(WelcomeText).order_by(WelcomeText.updated_at.desc())
|
||||
if not include_inactive:
|
||||
query = query.where(WelcomeText.is_active == True)
|
||||
|
||||
result = await db.execute(query.limit(limit).offset(offset))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int:
|
||||
query = select(func.count(WelcomeText.id))
|
||||
if not include_inactive:
|
||||
query = query.where(WelcomeText.is_active == True)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
|
||||
try:
|
||||
result = await db.execute(
|
||||
@@ -113,6 +144,87 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def create_welcome_text(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
text_content: str,
|
||||
created_by: Optional[int] = None,
|
||||
is_enabled: bool = True,
|
||||
is_active: bool = True,
|
||||
) -> WelcomeText:
|
||||
resolved_creator = created_by
|
||||
|
||||
if created_by is not None:
|
||||
result = await db.execute(select(User.id).where(User.id == created_by))
|
||||
resolved_creator = result.scalar_one_or_none()
|
||||
|
||||
if is_active:
|
||||
await db.execute(update(WelcomeText).values(is_active=False))
|
||||
|
||||
welcome_text = WelcomeText(
|
||||
text_content=text_content,
|
||||
is_active=is_active,
|
||||
is_enabled=is_enabled,
|
||||
created_by=resolved_creator,
|
||||
)
|
||||
|
||||
db.add(welcome_text)
|
||||
await db.commit()
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
logger.info(
|
||||
"✅ Создан приветственный текст ID %s (активный=%s, включен=%s)",
|
||||
welcome_text.id,
|
||||
welcome_text.is_active,
|
||||
welcome_text.is_enabled,
|
||||
)
|
||||
return welcome_text
|
||||
|
||||
|
||||
async def update_welcome_text(
|
||||
db: AsyncSession,
|
||||
welcome_text: WelcomeText,
|
||||
*,
|
||||
text_content: Optional[str] = None,
|
||||
is_enabled: Optional[bool] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> WelcomeText:
|
||||
if is_active:
|
||||
await db.execute(
|
||||
update(WelcomeText)
|
||||
.where(WelcomeText.id != welcome_text.id)
|
||||
.values(is_active=False)
|
||||
)
|
||||
|
||||
if text_content is not None:
|
||||
welcome_text.text_content = text_content
|
||||
|
||||
if is_enabled is not None:
|
||||
welcome_text.is_enabled = is_enabled
|
||||
|
||||
if is_active is not None:
|
||||
welcome_text.is_active = is_active
|
||||
|
||||
welcome_text.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
logger.info(
|
||||
"📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)",
|
||||
welcome_text.id,
|
||||
welcome_text.is_active,
|
||||
welcome_text.is_enabled,
|
||||
)
|
||||
return welcome_text
|
||||
|
||||
|
||||
async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None:
|
||||
await db.delete(welcome_text)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id)
|
||||
|
||||
async def get_current_welcome_text_or_default() -> str:
|
||||
return (
|
||||
f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! "
|
||||
|
||||
+360
-14
@@ -1,31 +1,167 @@
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from typing import AsyncGenerator, Optional
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
AsyncEngine
|
||||
)
|
||||
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
|
||||
from sqlalchemy import event, text, bindparam, inspect
|
||||
from sqlalchemy.engine import Engine
|
||||
import time
|
||||
from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# PRODUCTION-GRADE CONNECTION POOLING
|
||||
# ============================================================================
|
||||
|
||||
if settings.get_database_url().startswith("sqlite"):
|
||||
poolclass = NullPool
|
||||
pool_kwargs = {}
|
||||
else:
|
||||
poolclass = AsyncAdaptedQueuePool
|
||||
pool_kwargs = {
|
||||
"pool_size": 20,
|
||||
"max_overflow": 30,
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600,
|
||||
"pool_pre_ping": True,
|
||||
# 🔥 Агрессивная очистка мертвых соединений
|
||||
"pool_reset_on_return": "rollback",
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# ENGINE WITH ADVANCED OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.get_database_url(),
|
||||
poolclass=NullPool,
|
||||
poolclass=poolclass,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
future=True,
|
||||
**pool_kwargs,
|
||||
|
||||
connect_args={
|
||||
"server_settings": {
|
||||
"application_name": "remnawave_bot",
|
||||
"jit": "on",
|
||||
"statement_timeout": "60000", # 60 секунд
|
||||
"idle_in_transaction_session_timeout": "300000", # 5 минут
|
||||
},
|
||||
"command_timeout": 60,
|
||||
"timeout": 10,
|
||||
} if not settings.get_database_url().startswith("sqlite") else {},
|
||||
|
||||
execution_options={
|
||||
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
|
||||
"compiled_cache_size": 500, # Кеш скомпилированных запросов
|
||||
}
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# SESSION FACTORY WITH OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
autoflush=False, # 🔥 Критично для производительности
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# QUERY PERFORMANCE MONITORING
|
||||
# ============================================================================
|
||||
|
||||
if settings.DEBUG:
|
||||
@event.listens_for(Engine, "before_cursor_execute")
|
||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
conn.info.setdefault("query_start_time", []).append(time.time())
|
||||
logger.debug(f"🔍 Executing query: {statement[:100]}...")
|
||||
|
||||
@event.listens_for(Engine, "after_cursor_execute")
|
||||
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
total = time.time() - conn.info["query_start_time"].pop(-1)
|
||||
if total > 0.1: # Логируем медленные запросы > 100ms
|
||||
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
|
||||
else:
|
||||
logger.debug(f"⚡ Query executed in {total:.3f}s")
|
||||
|
||||
# ============================================================================
|
||||
# ADVANCED SESSION MANAGER WITH READ REPLICAS
|
||||
# ============================================================================
|
||||
|
||||
class DatabaseManager:
|
||||
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
|
||||
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
self.read_replica_engine: Optional[AsyncEngine] = None
|
||||
|
||||
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
|
||||
self.read_replica_engine = create_async_engine(
|
||||
settings.DATABASE_READ_REPLICA_URL,
|
||||
poolclass=poolclass,
|
||||
pool_size=30, # Больше для read операций
|
||||
max_overflow=50,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self, read_only: bool = False):
|
||||
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
bind=target_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
if not read_only:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def health_check(self) -> dict:
|
||||
pool = self.engine.pool
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
start = time.time()
|
||||
await session.execute(text("SELECT 1"))
|
||||
latency = (time.time() - start) * 1000
|
||||
status = "healthy"
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Database health check failed: {e}")
|
||||
status = "unhealthy"
|
||||
latency = None
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"latency_ms": round(latency, 2) if latency else None,
|
||||
"pool": _collect_health_pool_metrics(pool),
|
||||
}
|
||||
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
# ============================================================================
|
||||
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
|
||||
# ============================================================================
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Стандартная dependency для FastAPI"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
@@ -33,19 +169,229 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Read-only dependency для тяжелых SELECT запросов"""
|
||||
async with db_manager.session(read_only=True) as session:
|
||||
yield session
|
||||
|
||||
# ============================================================================
|
||||
# BATCH OPERATIONS FOR PERFORMANCE
|
||||
# ============================================================================
|
||||
|
||||
class BatchOperations:
|
||||
"""Утилиты для массовых операций"""
|
||||
|
||||
@staticmethod
|
||||
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовая вставка с чанками"""
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
session.add_all([model(**item) for item in chunk])
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовое обновление с чанками"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
primary_keys = [column.name for column in model.__table__.primary_key.columns]
|
||||
if not primary_keys:
|
||||
raise ValueError("Model must have a primary key for bulk_update")
|
||||
|
||||
updatable_columns = [
|
||||
column.name
|
||||
for column in model.__table__.columns
|
||||
if column.name not in primary_keys
|
||||
]
|
||||
|
||||
if not updatable_columns:
|
||||
raise ValueError("No columns available for update in bulk_update")
|
||||
|
||||
stmt = (
|
||||
model.__table__.update()
|
||||
.where(
|
||||
*[
|
||||
getattr(model.__table__.c, pk) == bindparam(pk)
|
||||
for pk in primary_keys
|
||||
]
|
||||
)
|
||||
.values(
|
||||
**{
|
||||
column: bindparam(column, required=False)
|
||||
for column in updatable_columns
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
filtered_chunk = []
|
||||
for item in chunk:
|
||||
missing_keys = [pk for pk in primary_keys if pk not in item]
|
||||
if missing_keys:
|
||||
raise ValueError(
|
||||
f"Missing primary key values {missing_keys} for bulk_update"
|
||||
)
|
||||
|
||||
filtered_item = {
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key in primary_keys or key in updatable_columns
|
||||
}
|
||||
filtered_chunk.append(filtered_item)
|
||||
|
||||
await session.execute(stmt, filtered_chunk)
|
||||
await session.commit()
|
||||
|
||||
batch_ops = BatchOperations()
|
||||
|
||||
# ============================================================================
|
||||
# INITIALIZATION AND CLEANUP
|
||||
# ============================================================================
|
||||
|
||||
async def init_db():
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
"""Инициализация БД с оптимизациями"""
|
||||
logger.info("🚀 Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
if not settings.get_database_url().startswith("sqlite"):
|
||||
logger.info("📊 Создание индексов для оптимизации...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
indexes = [
|
||||
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
|
||||
),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
|
||||
),
|
||||
(
|
||||
"payments",
|
||||
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
|
||||
),
|
||||
]
|
||||
|
||||
for table_name, index_sql in indexes:
|
||||
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
|
||||
|
||||
if not table_exists:
|
||||
logger.debug(
|
||||
"Пропускаем создание индекса %s: таблица %s отсутствует",
|
||||
index_sql,
|
||||
table_name,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await conn.execute(text(index_sql))
|
||||
except Exception as e:
|
||||
logger.debug("Index creation skipped for %s: %s", table_name, e)
|
||||
|
||||
logger.info("✅ База данных успешно инициализирована")
|
||||
|
||||
|
||||
health = await db_manager.health_check()
|
||||
logger.info(f"📊 Database health: {health}")
|
||||
|
||||
async def close_db():
|
||||
"""Корректное закрытие всех соединений"""
|
||||
logger.info("🔄 Закрытие соединений с БД...")
|
||||
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
|
||||
if db_manager.read_replica_engine:
|
||||
await db_manager.read_replica_engine.dispose()
|
||||
|
||||
logger.info("✅ Все подключения к базе данных закрыты")
|
||||
|
||||
# ============================================================================
|
||||
# CONNECTION POOL METRICS (для мониторинга)
|
||||
# ============================================================================
|
||||
|
||||
def _pool_counters(pool):
|
||||
"""Return basic pool counters or ``None`` when unsupported."""
|
||||
|
||||
required_methods = ("size", "checkedin", "checkedout", "overflow")
|
||||
|
||||
for method_name in required_methods:
|
||||
method = getattr(pool, method_name, None)
|
||||
if method is None or not callable(method):
|
||||
return None
|
||||
|
||||
size = pool.size()
|
||||
checked_in = pool.checkedin()
|
||||
checked_out = pool.checkedout()
|
||||
overflow = pool.overflow()
|
||||
|
||||
total_connections = size + overflow
|
||||
|
||||
return {
|
||||
"size": size,
|
||||
"checked_in": checked_in,
|
||||
"checked_out": checked_out,
|
||||
"overflow": overflow,
|
||||
"total_connections": total_connections,
|
||||
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _collect_health_pool_metrics(pool) -> dict:
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"size": 0,
|
||||
"checked_in": 0,
|
||||
"checked_out": 0,
|
||||
"overflow": 0,
|
||||
"total_connections": 0,
|
||||
"utilization": "0.0%",
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"size": counters["size"],
|
||||
"checked_in": counters["checked_in"],
|
||||
"checked_out": counters["checked_out"],
|
||||
"overflow": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"utilization": f"{counters['utilization_percent']:.1f}%",
|
||||
}
|
||||
|
||||
|
||||
async def get_pool_metrics() -> dict:
|
||||
"""Детальные метрики пула для Prometheus/Grafana"""
|
||||
pool = engine.pool
|
||||
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"pool_size": 0,
|
||||
"checked_in_connections": 0,
|
||||
"checked_out_connections": 0,
|
||||
"overflow_connections": 0,
|
||||
"total_connections": 0,
|
||||
"max_possible_connections": 0,
|
||||
"pool_utilization_percent": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"pool_size": counters["size"],
|
||||
"checked_in_connections": counters["checked_in"],
|
||||
"checked_out_connections": counters["checked_out"],
|
||||
"overflow_connections": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
|
||||
"pool_utilization_percent": round(counters["utilization_percent"], 2),
|
||||
}
|
||||
|
||||
+493
-21
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime, timedelta, time, date
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -7,6 +7,8 @@ from sqlalchemy import (
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
Date,
|
||||
Time,
|
||||
Boolean,
|
||||
Text,
|
||||
ForeignKey,
|
||||
@@ -16,6 +18,7 @@ from sqlalchemy import (
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
Table,
|
||||
SmallInteger,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
@@ -58,17 +61,19 @@ class SubscriptionStatus(Enum):
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
POLL_REWARD = "poll_reward"
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
PROMO_GROUP = "promo_group"
|
||||
|
||||
|
||||
class PaymentMethod(Enum):
|
||||
@@ -76,9 +81,11 @@ class PaymentMethod(Enum):
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
CRYPTOBOT = "cryptobot"
|
||||
HELEKET = "heleket"
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
WATA = "wata"
|
||||
PLATEGA = "platega"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
@@ -190,6 +197,72 @@ class CryptoBotPayment(Base):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class HeleketPayment(Base):
|
||||
__tablename__ = "heleket_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
order_id = Column(String(128), unique=True, nullable=False, index=True)
|
||||
|
||||
amount = Column(String(50), nullable=False)
|
||||
currency = Column(String(10), nullable=False)
|
||||
payer_amount = Column(String(50), nullable=True)
|
||||
payer_currency = Column(String(10), nullable=True)
|
||||
exchange_rate = Column(Float, nullable=True)
|
||||
discount_percent = Column(Integer, nullable=True)
|
||||
|
||||
status = Column(String(50), nullable=False)
|
||||
payment_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="heleket_payments")
|
||||
transaction = relationship("Transaction", backref="heleket_payment")
|
||||
|
||||
@property
|
||||
def amount_float(self) -> float:
|
||||
try:
|
||||
return float(self.amount)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def amount_kopeks(self) -> int:
|
||||
return int(round(self.amount_float * 100))
|
||||
|
||||
@property
|
||||
def payer_amount_float(self) -> float:
|
||||
try:
|
||||
return float(self.payer_amount) if self.payer_amount is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
return self.status in {"paid", "paid_over"}
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
"<HeleketPayment(id={id}, uuid={uuid}, order_id={order_id}, amount={amount}"
|
||||
" {currency}, status={status})>"
|
||||
).format(
|
||||
id=self.id,
|
||||
uuid=self.uuid,
|
||||
order_id=self.order_id,
|
||||
amount=self.amount,
|
||||
currency=self.currency,
|
||||
status=self.status,
|
||||
)
|
||||
|
||||
|
||||
class MulenPayPayment(Base):
|
||||
__tablename__ = "mulenpay_payments"
|
||||
|
||||
@@ -345,11 +418,62 @@ class WataPayment(Base):
|
||||
)
|
||||
|
||||
|
||||
class PlategaPayment(Base):
|
||||
__tablename__ = "platega_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
payment_method_code = Column(Integer, nullable=False)
|
||||
status = Column(String(50), nullable=False, default="PENDING")
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
redirect_url = Column(Text, nullable=True)
|
||||
return_url = Column(Text, nullable=True)
|
||||
failed_url = Column(Text, nullable=True)
|
||||
payload = Column(String(255), nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="platega_payments")
|
||||
transaction = relationship("Transaction", backref="platega_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
|
||||
self.id,
|
||||
self.platega_transaction_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
self.payment_method_code,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), unique=True, nullable=False)
|
||||
priority = Column(Integer, nullable=False, default=0, index=True)
|
||||
server_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
@@ -361,6 +485,7 @@ class PromoGroup(Base):
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
users = relationship("User", back_populates="promo_group")
|
||||
user_promo_groups = relationship("UserPromoGroup", back_populates="promo_group", cascade="all, delete-orphan")
|
||||
server_squads = relationship(
|
||||
"ServerSquad",
|
||||
secondary=server_squad_promo_groups,
|
||||
@@ -419,11 +544,31 @@ class PromoGroup(Base):
|
||||
"traffic": self.traffic_discount_percent,
|
||||
"devices": self.device_discount_percent,
|
||||
}
|
||||
percent = mapping.get(category, 0)
|
||||
percent = mapping.get(category) or 0
|
||||
|
||||
if percent == 0 and self.is_default:
|
||||
base_period_discount = self._get_period_discount(period_days)
|
||||
percent = max(percent, base_period_discount)
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
class UserPromoGroup(Base):
|
||||
"""Таблица связи Many-to-Many между пользователями и промогруппами."""
|
||||
__tablename__ = "user_promo_groups"
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="CASCADE"), primary_key=True)
|
||||
assigned_at = Column(DateTime, default=func.now())
|
||||
assigned_by = Column(String(50), default="system")
|
||||
|
||||
user = relationship("User", back_populates="user_promo_groups")
|
||||
promo_group = relationship("PromoGroup", back_populates="user_promo_groups")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -453,6 +598,7 @@ class User(Base):
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
||||
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
|
||||
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
|
||||
referral_commission_percent = Column(Integer, nullable=True)
|
||||
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
promo_offer_discount_source = Column(String(100), nullable=True)
|
||||
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
|
||||
@@ -461,22 +607,51 @@ class User(Base):
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
ss_password = Column(String(255), nullable=True)
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
|
||||
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
|
||||
poll_responses = relationship("PollResponse", back_populates="user")
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
return self.balance_kopeks / 100
|
||||
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
return getattr(self, "promo_group", None)
|
||||
|
||||
try:
|
||||
# Сортируем по приоритету группы (убывание), затем по ID группы
|
||||
# Используем getattr для защиты от ленивой загрузки
|
||||
sorted_groups = sorted(
|
||||
self.user_promo_groups,
|
||||
key=lambda upg: (
|
||||
getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0,
|
||||
upg.promo_group_id
|
||||
),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if sorted_groups and sorted_groups[0].promo_group:
|
||||
return sorted_groups[0].promo_group
|
||||
except Exception:
|
||||
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
|
||||
pass
|
||||
|
||||
# Fallback на старую связь если новая пустая или возникла ошибка
|
||||
return getattr(self, "promo_group", None)
|
||||
|
||||
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
if not self.promo_group:
|
||||
primary_group = self.get_primary_promo_group()
|
||||
if not primary_group:
|
||||
return 0
|
||||
return self.promo_group.get_discount_percent(category, period_days)
|
||||
return primary_group.get_discount_percent(category, period_days)
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
@@ -724,13 +899,15 @@ class PromoCode(Base):
|
||||
valid_until = Column(DateTime, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
uses = relationship("PromoCodeUse", back_populates="promocode")
|
||||
promo_group = relationship("PromoGroup")
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
@@ -783,6 +960,132 @@ class ReferralEarning(Base):
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
|
||||
class ReferralContest(Base):
|
||||
__tablename__ = "referral_contests"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
prize_text = Column(Text, nullable=True)
|
||||
contest_type = Column(String(50), nullable=False, default="referral_paid")
|
||||
start_at = Column(DateTime, nullable=False)
|
||||
end_at = Column(DateTime, nullable=False)
|
||||
daily_summary_time = Column(Time, nullable=False, default=time(hour=12, minute=0))
|
||||
daily_summary_times = Column(String(255), nullable=True) # CSV HH:MM
|
||||
timezone = Column(String(64), nullable=False, default="UTC")
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
last_daily_summary_date = Column(Date, nullable=True)
|
||||
last_daily_summary_at = Column(DateTime, nullable=True)
|
||||
final_summary_sent = Column(Boolean, nullable=False, default=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", backref="created_referral_contests")
|
||||
events = relationship(
|
||||
"ReferralContestEvent",
|
||||
back_populates="contest",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ReferralContest id={self.id} title='{self.title}'>"
|
||||
|
||||
|
||||
class ReferralContestEvent(Base):
|
||||
__tablename__ = "referral_contest_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"contest_id",
|
||||
"referral_id",
|
||||
name="uq_referral_contest_referral",
|
||||
),
|
||||
Index("idx_referral_contest_referrer", "contest_id", "referrer_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
contest_id = Column(Integer, ForeignKey("referral_contests.id", ondelete="CASCADE"), nullable=False)
|
||||
referrer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
referral_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
event_type = Column(String(50), nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
occurred_at = Column(DateTime, nullable=False, default=func.now())
|
||||
|
||||
contest = relationship("ReferralContest", back_populates="events")
|
||||
referrer = relationship("User", foreign_keys=[referrer_id])
|
||||
referral = relationship("User", foreign_keys=[referral_id])
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<ReferralContestEvent contest={self.contest_id} "
|
||||
f"referrer={self.referrer_id} referral={self.referral_id}>"
|
||||
)
|
||||
|
||||
|
||||
class ContestTemplate(Base):
|
||||
__tablename__ = "contest_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
slug = Column(String(50), nullable=False, unique=True, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
prize_days = Column(Integer, nullable=False, default=1)
|
||||
max_winners = Column(Integer, nullable=False, default=1)
|
||||
attempts_per_user = Column(Integer, nullable=False, default=1)
|
||||
times_per_day = Column(Integer, nullable=False, default=1)
|
||||
schedule_times = Column(String(255), nullable=True) # CSV of HH:MM in local TZ
|
||||
cooldown_hours = Column(Integer, nullable=False, default=24)
|
||||
payload = Column(JSON, nullable=True)
|
||||
is_enabled = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
rounds = relationship("ContestRound", back_populates="template")
|
||||
|
||||
|
||||
class ContestRound(Base):
|
||||
__tablename__ = "contest_rounds"
|
||||
__table_args__ = (
|
||||
Index("idx_contest_round_status", "status"),
|
||||
Index("idx_contest_round_template", "template_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
template_id = Column(Integer, ForeignKey("contest_templates.id", ondelete="CASCADE"), nullable=False)
|
||||
starts_at = Column(DateTime, nullable=False)
|
||||
ends_at = Column(DateTime, nullable=False)
|
||||
status = Column(String(20), nullable=False, default="active") # active, finished
|
||||
payload = Column(JSON, nullable=True)
|
||||
winners_count = Column(Integer, nullable=False, default=0)
|
||||
max_winners = Column(Integer, nullable=False, default=1)
|
||||
attempts_per_user = Column(Integer, nullable=False, default=1)
|
||||
message_id = Column(BigInteger, nullable=True)
|
||||
chat_id = Column(BigInteger, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
template = relationship("ContestTemplate", back_populates="rounds")
|
||||
attempts = relationship("ContestAttempt", back_populates="round", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ContestAttempt(Base):
|
||||
__tablename__ = "contest_attempts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("round_id", "user_id", name="uq_round_user_attempt"),
|
||||
Index("idx_contest_attempt_round", "round_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
round_id = Column(Integer, ForeignKey("contest_rounds.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
answer = Column(Text, nullable=True)
|
||||
is_winner = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
round = relationship("ContestRound", back_populates="attempts")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class Squad(Base):
|
||||
__tablename__ = "squads"
|
||||
|
||||
@@ -909,6 +1212,30 @@ class SentNotification(Base):
|
||||
subscription = relationship("Subscription", backref="sent_notifications")
|
||||
|
||||
|
||||
class SubscriptionEvent(Base):
|
||||
__tablename__ = "subscription_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
event_type = Column(String(50), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
subscription_id = Column(
|
||||
Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
transaction_id = Column(
|
||||
Integer, ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
amount_kopeks = Column(Integer, nullable=True)
|
||||
currency = Column(String(16), nullable=True)
|
||||
message = Column(Text, nullable=True)
|
||||
occurred_at = Column(DateTime, nullable=False, default=func.now())
|
||||
extra = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", backref="subscription_events")
|
||||
subscription = relationship("Subscription", backref="subscription_events")
|
||||
transaction = relationship("Transaction", backref="subscription_events")
|
||||
|
||||
|
||||
class DiscountOffer(Base):
|
||||
__tablename__ = "discount_offers"
|
||||
__table_args__ = (
|
||||
@@ -994,9 +1321,9 @@ class PromoOfferLog(Base):
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
@@ -1012,6 +1339,106 @@ class BroadcastHistory(Base):
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
|
||||
class Poll(Base):
|
||||
__tablename__ = "polls"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
reward_enabled = Column(Boolean, nullable=False, default=False)
|
||||
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
creator = relationship("User", backref="created_polls", foreign_keys=[created_by])
|
||||
questions = relationship(
|
||||
"PollQuestion",
|
||||
back_populates="poll",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PollQuestion.order",
|
||||
)
|
||||
responses = relationship(
|
||||
"PollResponse",
|
||||
back_populates="poll",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class PollQuestion(Base):
|
||||
__tablename__ = "poll_questions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
poll = relationship("Poll", back_populates="questions")
|
||||
options = relationship(
|
||||
"PollOption",
|
||||
back_populates="question",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PollOption.order",
|
||||
)
|
||||
answers = relationship("PollAnswer", back_populates="question")
|
||||
|
||||
|
||||
class PollOption(Base):
|
||||
__tablename__ = "poll_options"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
text = Column(Text, nullable=False)
|
||||
order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
question = relationship("PollQuestion", back_populates="options")
|
||||
answers = relationship("PollAnswer", back_populates="option")
|
||||
|
||||
|
||||
class PollResponse(Base):
|
||||
__tablename__ = "poll_responses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
sent_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
reward_given = Column(Boolean, nullable=False, default=False)
|
||||
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
|
||||
poll = relationship("Poll", back_populates="responses")
|
||||
user = relationship("User", back_populates="poll_responses")
|
||||
answers = relationship(
|
||||
"PollAnswer",
|
||||
back_populates="response",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
|
||||
)
|
||||
|
||||
|
||||
class PollAnswer(Base):
|
||||
__tablename__ = "poll_answers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now(), nullable=False)
|
||||
|
||||
response = relationship("PollResponse", back_populates="answers")
|
||||
question = relationship("PollQuestion", back_populates="answers")
|
||||
option = relationship("PollOption", back_populates="answers")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
|
||||
)
|
||||
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
@@ -1355,3 +1782,48 @@ class MainMenuButton(Base):
|
||||
f"<MainMenuButton id={self.id} text='{self.text}' "
|
||||
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
|
||||
)
|
||||
|
||||
|
||||
class MenuLayoutHistory(Base):
|
||||
"""История изменений конфигурации меню."""
|
||||
__tablename__ = "menu_layout_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
config_json = Column(Text, nullable=False) # Полная конфигурация в JSON
|
||||
action = Column(String(50), nullable=False) # update, reset, import
|
||||
changes_summary = Column(Text, nullable=True) # Краткое описание изменений
|
||||
user_info = Column(String(255), nullable=True) # Информация о пользователе/токене
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_menu_layout_history_created", "created_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MenuLayoutHistory id={self.id} action='{self.action}' created_at={self.created_at}>"
|
||||
|
||||
|
||||
class ButtonClickLog(Base):
|
||||
"""Логи кликов по кнопкам меню."""
|
||||
__tablename__ = "button_click_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
button_id = Column(String(100), nullable=False, index=True) # ID кнопки
|
||||
user_id = Column(BigInteger, ForeignKey("users.telegram_id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
callback_data = Column(String(255), nullable=True) # callback_data кнопки
|
||||
clicked_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# Дополнительная информация
|
||||
button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app
|
||||
button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_button_click_logs_button_date", "button_id", "clicked_at"),
|
||||
Index("ix_button_click_logs_user_date", "user_id", "clicked_at"),
|
||||
)
|
||||
|
||||
# Связи
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ButtonClickLog id={self.id} button='{self.button_id}' user={self.user_id} at={self.clicked_at}>"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+34
-14
@@ -19,10 +19,10 @@ class CryptoBotService:
|
||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.api_token:
|
||||
@@ -37,11 +37,18 @@ class CryptoBotService:
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
request_kwargs: Dict[str, Any] = {"headers": headers}
|
||||
|
||||
if method.upper() == "GET":
|
||||
if data:
|
||||
request_kwargs["params"] = data
|
||||
elif data:
|
||||
request_kwargs["json"] = data
|
||||
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=data if data else None
|
||||
method,
|
||||
url,
|
||||
**request_kwargs,
|
||||
) as response:
|
||||
|
||||
response_data = await response.json()
|
||||
@@ -95,21 +102,34 @@ class CryptoBotService:
|
||||
asset: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
count: int = 100
|
||||
count: int = 100,
|
||||
invoice_ids: Optional[list] = None,
|
||||
) -> Optional[list]:
|
||||
|
||||
|
||||
data = {
|
||||
'offset': offset,
|
||||
'count': count
|
||||
}
|
||||
|
||||
|
||||
if asset:
|
||||
data['asset'] = asset
|
||||
|
||||
|
||||
if status:
|
||||
data['status'] = status
|
||||
|
||||
return await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
if invoice_ids:
|
||||
data['invoice_ids'] = invoice_ids
|
||||
|
||||
result = await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
if isinstance(result, dict):
|
||||
items = result.get('items')
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
|
||||
return []
|
||||
|
||||
async def get_balance(self) -> Optional[list]:
|
||||
return await self._make_request('GET', 'getBalance')
|
||||
|
||||
Vendored
+174
@@ -0,0 +1,174 @@
|
||||
"""HTTP client for Heleket payment API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeleketService:
|
||||
"""Minimal wrapper around Heleket API endpoints."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.HELEKET_BASE_URL.rstrip("/")
|
||||
self.merchant_id = settings.HELEKET_MERCHANT_ID
|
||||
self.api_key = settings.HELEKET_API_KEY
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.merchant_id and self.api_key)
|
||||
|
||||
def _prepare_body(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
ignore_none: bool,
|
||||
sort_keys: bool,
|
||||
) -> str:
|
||||
if ignore_none:
|
||||
cleaned = {key: value for key, value in payload.items() if value is not None}
|
||||
else:
|
||||
cleaned = dict(payload)
|
||||
|
||||
serialized = json.dumps(
|
||||
cleaned,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=sort_keys,
|
||||
)
|
||||
|
||||
if "/" in serialized:
|
||||
serialized = serialized.replace("/", "\\/")
|
||||
|
||||
return serialized
|
||||
|
||||
def _generate_signature(self, body: str) -> str:
|
||||
api_key = self.api_key or ""
|
||||
encoded = base64.b64encode(body.encode("utf-8")).decode("utf-8")
|
||||
raw = f"{encoded}{api_key}"
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
||||
return None
|
||||
|
||||
body = self._prepare_body(payload, ignore_none=True, sort_keys=True)
|
||||
signature = self._generate_signature(body)
|
||||
|
||||
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||
headers = {
|
||||
"merchant": self.merchant_id or "",
|
||||
"sign": signature,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
headers=headers,
|
||||
params=params,
|
||||
) as response:
|
||||
text = await response.text()
|
||||
if response.content_type != "application/json":
|
||||
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Ошибка парсинга Heleket JSON: %s", text)
|
||||
return None
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error("Heleket API %s вернул статус %s: %s", endpoint, response.status, data)
|
||||
return None
|
||||
|
||||
if isinstance(data, dict) and data.get("state") == 0:
|
||||
return data
|
||||
|
||||
logger.error("Heleket API вернул ошибку: %s", data)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.error("Ошибка запроса к Heleket API: %s", error)
|
||||
return None
|
||||
|
||||
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("payment", payload)
|
||||
|
||||
async def get_payment_info(
|
||||
self,
|
||||
*,
|
||||
uuid: Optional[str] = None,
|
||||
order_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not uuid and not order_id:
|
||||
raise ValueError("Нужно указать uuid или order_id для Heleket payment/info")
|
||||
|
||||
payload: Dict[str, Any] = {}
|
||||
if uuid:
|
||||
payload["uuid"] = uuid
|
||||
if order_id:
|
||||
payload["order_id"] = order_id
|
||||
|
||||
return await self._request("payment/info", payload)
|
||||
|
||||
async def list_payments(
|
||||
self,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
cursor: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if date_from:
|
||||
payload["date_from"] = date_from
|
||||
if date_to:
|
||||
payload["date_to"] = date_to
|
||||
|
||||
params = {"cursor": cursor} if cursor else None
|
||||
return await self._request("payment/list", payload, params=params)
|
||||
|
||||
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
if not self.is_configured:
|
||||
logger.warning("Heleket сервис не настроен, подпись пропускается")
|
||||
return True
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("Heleket webhook payload не dict: %s", payload)
|
||||
return False
|
||||
|
||||
signature = payload.get("sign")
|
||||
if not signature:
|
||||
logger.error("Heleket webhook без подписи")
|
||||
return False
|
||||
|
||||
data = dict(payload)
|
||||
data.pop("sign", None)
|
||||
body = self._prepare_body(data, ignore_none=False, sort_keys=False)
|
||||
expected = self._generate_signature(body)
|
||||
|
||||
is_valid = hmac.compare_digest(expected, str(signature))
|
||||
|
||||
if not is_valid:
|
||||
logger.error("Неверная подпись Heleket webhook: ожидается %s, получено %s", expected, signature)
|
||||
return is_valid
|
||||
Vendored
+111
@@ -0,0 +1,111 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.external.heleket import HeleketService
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeleketWebhookHandler:
|
||||
def __init__(self, payment_service: PaymentService) -> None:
|
||||
self.payment_service = payment_service
|
||||
self.service = HeleketService()
|
||||
|
||||
async def handle(self, request: web.Request) -> web.Response:
|
||||
if not settings.is_heleket_enabled():
|
||||
logger.warning("Получен Heleket webhook, но сервис отключен")
|
||||
return web.json_response({"status": "error", "reason": "disabled"}, status=503)
|
||||
|
||||
try:
|
||||
payload: Dict[str, Any] = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON Heleket webhook")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
if not self.service.verify_webhook_signature(payload):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
processed: Optional[bool] = None
|
||||
async for db in get_db():
|
||||
processed = await self.payment_service.process_heleket_webhook(db, payload)
|
||||
|
||||
if processed:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
|
||||
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
|
||||
|
||||
async def health_check(self, _: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "heleket_webhook",
|
||||
"enabled": settings.is_heleket_enabled(),
|
||||
"path": settings.HELEKET_WEBHOOK_PATH,
|
||||
}
|
||||
)
|
||||
|
||||
async def options_handler(self, _: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_heleket_app(payment_service: PaymentService) -> web.Application:
|
||||
handler = HeleketWebhookHandler(payment_service)
|
||||
app = web.Application()
|
||||
app.router.add_post(settings.HELEKET_WEBHOOK_PATH, handler.handle)
|
||||
app.router.add_get("/heleket/health", handler.health_check)
|
||||
app.router.add_get("/health", handler.health_check)
|
||||
app.router.add_options(settings.HELEKET_WEBHOOK_PATH, handler.options_handler)
|
||||
return app
|
||||
|
||||
|
||||
async def start_heleket_webhook_server(payment_service: PaymentService) -> None:
|
||||
if not settings.is_heleket_enabled():
|
||||
logger.info("Heleket отключен, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
app = create_heleket_app(payment_service)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.HELEKET_WEBHOOK_HOST,
|
||||
port=settings.HELEKET_WEBHOOK_PORT,
|
||||
)
|
||||
|
||||
try:
|
||||
await site.start()
|
||||
logger.info(
|
||||
"Heleket webhook сервер запущен на %s:%s",
|
||||
settings.HELEKET_WEBHOOK_HOST,
|
||||
settings.HELEKET_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"Heleket webhook URL: http://%s:%s%s",
|
||||
settings.HELEKET_WEBHOOK_HOST,
|
||||
settings.HELEKET_WEBHOOK_PORT,
|
||||
settings.HELEKET_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Heleket webhook сервер остановлен по запросу")
|
||||
finally:
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("Heleket webhook сервер корректно остановлен")
|
||||
Vendored
+4
-4
@@ -1,4 +1,4 @@
|
||||
"""Flask webhook server for PayPalych postbacks."""
|
||||
"""Flask webhook server for PayPalych callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -65,7 +65,7 @@ def create_pal24_flask_app(
|
||||
return jsonify({"status": "error", "reason": "empty_payload"}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_postback(payload)
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка валидации Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
@@ -73,7 +73,7 @@ def create_pal24_flask_app(
|
||||
async def process() -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_postback(db, parsed_payload)
|
||||
return await payment_service.process_pal24_callback(db, parsed_payload)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
@@ -112,7 +112,7 @@ def create_pal24_flask_app(
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 postbacks."""
|
||||
"""Threaded Flask server for Pal24 callbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
|
||||
Vendored
+552
-53
@@ -27,14 +27,22 @@ class TrafficLimitStrategy(Enum):
|
||||
MONTH = "MONTH"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserTraffic:
|
||||
"""Данные о трафике пользователя (новая структура API)"""
|
||||
used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int
|
||||
online_at: Optional[datetime] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_connected_node_uuid: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveUser:
|
||||
uuid: str
|
||||
short_uuid: str
|
||||
username: str
|
||||
status: UserStatus
|
||||
used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int
|
||||
traffic_limit_bytes: int
|
||||
traffic_limit_strategy: TrafficLimitStrategy
|
||||
expire_at: datetime
|
||||
@@ -47,18 +55,60 @@ class RemnaWaveUser:
|
||||
active_internal_squads: List[Dict[str, str]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
user_traffic: Optional[UserTraffic] = None
|
||||
sub_last_user_agent: Optional[str] = None
|
||||
sub_last_opened_at: Optional[datetime] = None
|
||||
online_at: Optional[datetime] = None
|
||||
sub_revoked_at: Optional[datetime] = None
|
||||
last_traffic_reset_at: Optional[datetime] = None
|
||||
trojan_password: Optional[str] = None
|
||||
vless_uuid: Optional[str] = None
|
||||
ss_password: Optional[str] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_triggered_threshold: int = 0
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
external_squad_uuid: Optional[str] = None
|
||||
id: Optional[int] = None
|
||||
|
||||
@property
|
||||
def used_traffic_bytes(self) -> int:
|
||||
"""Обратная совместимость: получение used_traffic_bytes из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.used_traffic_bytes
|
||||
return 0
|
||||
|
||||
@property
|
||||
def lifetime_used_traffic_bytes(self) -> int:
|
||||
"""Обратная совместимость: получение lifetime_used_traffic_bytes из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.lifetime_used_traffic_bytes
|
||||
return 0
|
||||
|
||||
@property
|
||||
def online_at(self) -> Optional[datetime]:
|
||||
"""Обратная совместимость: получение online_at из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.online_at
|
||||
return None
|
||||
|
||||
@property
|
||||
def first_connected_at(self) -> Optional[datetime]:
|
||||
"""Обратная совместимость: получение first_connected_at из user_traffic"""
|
||||
if self.user_traffic:
|
||||
return self.user_traffic.first_connected_at
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveInbound:
|
||||
"""Структура inbound для Internal Squad"""
|
||||
uuid: str
|
||||
profile_uuid: str
|
||||
tag: str
|
||||
type: str
|
||||
network: Optional[str] = None
|
||||
security: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
raw_inbound: Optional[Any] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -67,7 +117,21 @@ class RemnaWaveInternalSquad:
|
||||
name: str
|
||||
members_count: int
|
||||
inbounds_count: int
|
||||
inbounds: List[Dict[str, Any]]
|
||||
inbounds: List[RemnaWaveInbound]
|
||||
view_position: int = 0
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveAccessibleNode:
|
||||
"""Доступная нода для Internal Squad"""
|
||||
uuid: str
|
||||
node_name: str
|
||||
country_code: str
|
||||
config_profile_uuid: str
|
||||
config_profile_name: str
|
||||
active_inbounds: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -78,11 +142,39 @@ class RemnaWaveNode:
|
||||
country_code: str
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: Optional[int]
|
||||
traffic_used_bytes: Optional[int]
|
||||
traffic_limit_bytes: Optional[int]
|
||||
port: Optional[int] = None
|
||||
is_connecting: bool = False
|
||||
xray_version: Optional[str] = None
|
||||
node_version: Optional[str] = None
|
||||
view_position: int = 0
|
||||
tags: Optional[List[str]] = None
|
||||
# Новые поля API
|
||||
last_status_change: Optional[datetime] = None
|
||||
last_status_message: Optional[str] = None
|
||||
xray_uptime: Optional[str] = None
|
||||
is_traffic_tracking_active: bool = False
|
||||
traffic_reset_day: Optional[int] = None
|
||||
notify_percent: Optional[int] = None
|
||||
consumption_multiplier: float = 1.0
|
||||
cpu_count: Optional[int] = None
|
||||
cpu_model: Optional[str] = None
|
||||
total_ram: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
provider_uuid: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_node_online(self) -> bool:
|
||||
"""Обратная совместимость: is_node_online = is_connected"""
|
||||
return self.is_connected
|
||||
|
||||
@property
|
||||
def is_xray_running(self) -> bool:
|
||||
"""Обратная совместимость: xray работает если нода подключена"""
|
||||
return self.is_connected
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -97,6 +189,33 @@ class SubscriptionInfo:
|
||||
happ_crypto_link: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionPageConfig:
|
||||
"""Конфигурация страницы подписки"""
|
||||
uuid: str
|
||||
name: str
|
||||
view_position: int
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveExternalSquad:
|
||||
"""Структура External Squad"""
|
||||
uuid: str
|
||||
name: str
|
||||
view_position: int
|
||||
members_count: int
|
||||
templates: List[Dict[str, str]]
|
||||
subscription_settings: Optional[Dict[str, Any]] = None
|
||||
host_overrides: Optional[Dict[str, Any]] = None
|
||||
response_headers: Optional[Dict[str, str]] = None
|
||||
hwid_settings: Optional[Dict[str, Any]] = None
|
||||
custom_remarks: Optional[Dict[str, Any]] = None
|
||||
subpage_config_uuid: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
||||
self.message = message
|
||||
@@ -163,7 +282,7 @@ class RemnaWaveAPI:
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
headers = self._prepare_auth_headers()
|
||||
|
||||
@@ -299,12 +418,14 @@ class RemnaWaveAPI:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
return self._parse_user(response['response'])
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/{uuid}')
|
||||
return self._parse_user(response['response'])
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
@@ -316,7 +437,8 @@ class RemnaWaveAPI:
|
||||
users_data = response.get('response', [])
|
||||
if not users_data:
|
||||
return []
|
||||
return [self._parse_user(user) for user in users_data]
|
||||
users = [self._parse_user(user) for user in users_data]
|
||||
return [await self.enrich_user_with_happ_link(u) for u in users]
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
@@ -325,7 +447,8 @@ class RemnaWaveAPI:
|
||||
async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/by-username/{username}')
|
||||
return self._parse_user(response['response'])
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
@@ -369,38 +492,48 @@ class RemnaWaveAPI:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def delete_user(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/users/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
async def enable_user(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def disable_user(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser:
|
||||
data = {}
|
||||
if new_short_uuid:
|
||||
data['shortUuid'] = new_short_uuid
|
||||
|
||||
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
|
||||
return self._parse_user(response['response'])
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def get_all_users(self, start: int = 0, size: int = 100) -> Dict[str, Any]:
|
||||
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> Dict[str, Any]:
|
||||
params = {'start': start, 'size': size}
|
||||
response = await self._make_request('GET', '/api/users', params=params)
|
||||
|
||||
|
||||
users = [self._parse_user(user) for user in response['response']['users']]
|
||||
|
||||
if enrich_happ_links:
|
||||
users = [await self.enrich_user_with_happ_link(u) for u in users]
|
||||
|
||||
return {
|
||||
'users': [self._parse_user(user) for user in response['response']['users']],
|
||||
'users': users,
|
||||
'total': response['response']['total']
|
||||
}
|
||||
|
||||
@@ -444,8 +577,132 @@ class RemnaWaveAPI:
|
||||
async def delete_internal_squad(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
|
||||
|
||||
async def get_internal_squad_accessible_nodes(self, uuid: str) -> List[RemnaWaveAccessibleNode]:
|
||||
"""Получает список доступных нод для Internal Squad"""
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/internal-squads/{uuid}/accessible-nodes')
|
||||
return [self._parse_accessible_node(node) for node in response['response']['accessibleNodes']]
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
raise
|
||||
|
||||
async def add_users_to_internal_squad(self, uuid: str) -> bool:
|
||||
"""Добавляет всех пользователей в Internal Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/add-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def remove_users_from_internal_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет всех пользователей из Internal Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/remove-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def reorder_internal_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveInternalSquad]:
|
||||
"""
|
||||
Изменяет порядок Internal Squads
|
||||
items: список словарей с uuid и viewPosition
|
||||
Пример: [{'uuid': '...', 'viewPosition': 0}, {'uuid': '...', 'viewPosition': 1}]
|
||||
"""
|
||||
data = {'items': items}
|
||||
response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data)
|
||||
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
|
||||
|
||||
# ============== External Squads API ==============
|
||||
|
||||
async def get_external_squads(self) -> List[RemnaWaveExternalSquad]:
|
||||
"""Получает список всех External Squads"""
|
||||
response = await self._make_request('GET', '/api/external-squads')
|
||||
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
|
||||
|
||||
async def get_external_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveExternalSquad]:
|
||||
"""Получает External Squad по UUID"""
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/external-squads/{uuid}')
|
||||
return self._parse_external_squad(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def create_external_squad(self, name: str) -> RemnaWaveExternalSquad:
|
||||
data = {'name': name}
|
||||
response = await self._make_request('POST', '/api/external-squads', data)
|
||||
return self._parse_external_squad(response['response'])
|
||||
|
||||
async def update_external_squad(
|
||||
self,
|
||||
uuid: str,
|
||||
name: Optional[str] = None,
|
||||
templates: Optional[List[Dict[str, str]]] = None,
|
||||
subscription_settings: Optional[Dict[str, Any]] = None,
|
||||
host_overrides: Optional[Dict[str, Any]] = None,
|
||||
response_headers: Optional[Dict[str, str]] = None,
|
||||
hwid_settings: Optional[Dict[str, Any]] = None,
|
||||
custom_remarks: Optional[Dict[str, Any]] = None,
|
||||
subpage_config_uuid: Optional[str] = None
|
||||
) -> RemnaWaveExternalSquad:
|
||||
data = {'uuid': uuid}
|
||||
if name is not None:
|
||||
data['name'] = name
|
||||
if templates is not None:
|
||||
data['templates'] = templates
|
||||
if subscription_settings is not None:
|
||||
data['subscriptionSettings'] = subscription_settings
|
||||
if host_overrides is not None:
|
||||
data['hostOverrides'] = host_overrides
|
||||
if response_headers is not None:
|
||||
data['responseHeaders'] = response_headers
|
||||
if hwid_settings is not None:
|
||||
data['hwidSettings'] = hwid_settings
|
||||
if custom_remarks is not None:
|
||||
data['customRemarks'] = custom_remarks
|
||||
if subpage_config_uuid is not None:
|
||||
data['subpageConfigUuid'] = subpage_config_uuid
|
||||
|
||||
response = await self._make_request('PATCH', '/api/external-squads', data)
|
||||
return self._parse_external_squad(response['response'])
|
||||
|
||||
async def delete_external_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет External Squad"""
|
||||
response = await self._make_request('DELETE', f'/api/external-squads/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
async def add_users_to_external_squad(self, uuid: str) -> bool:
|
||||
"""Добавляет всех пользователей в External Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/add-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def remove_users_from_external_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет всех пользователей из External Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def reorder_external_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveExternalSquad]:
|
||||
data = {'items': items}
|
||||
response = await self._make_request('POST', '/api/external-squads/actions/reorder', data)
|
||||
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
|
||||
|
||||
def _parse_external_squad(self, squad_data: Dict) -> RemnaWaveExternalSquad:
|
||||
"""Парсит данные External Squad"""
|
||||
info = squad_data.get('info', {})
|
||||
return RemnaWaveExternalSquad(
|
||||
uuid=squad_data['uuid'],
|
||||
name=squad_data['name'],
|
||||
view_position=squad_data.get('viewPosition', 0),
|
||||
members_count=info.get('membersCount', 0),
|
||||
templates=squad_data.get('templates', []),
|
||||
subscription_settings=squad_data.get('subscriptionSettings'),
|
||||
host_overrides=squad_data.get('hostOverrides'),
|
||||
response_headers=squad_data.get('responseHeaders'),
|
||||
hwid_settings=squad_data.get('hwidSettings'),
|
||||
custom_remarks=squad_data.get('customRemarks'),
|
||||
subpage_config_uuid=squad_data.get('subpageConfigUuid'),
|
||||
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
|
||||
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
|
||||
)
|
||||
|
||||
|
||||
async def get_all_nodes(self) -> List[RemnaWaveNode]:
|
||||
response = await self._make_request('GET', '/api/nodes')
|
||||
return [self._parse_node(node) for node in response['response']]
|
||||
@@ -478,7 +735,13 @@ class RemnaWaveAPI:
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
|
||||
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
|
||||
return self._parse_subscription_info(response['response'])
|
||||
info = self._parse_subscription_info(response['response'])
|
||||
# Обогащаем happ_crypto_link если его нет но есть subscription_url
|
||||
if not info.happ_crypto_link and info.subscription_url:
|
||||
encrypted = await self.encrypt_happ_crypto_link(info.subscription_url)
|
||||
if encrypted:
|
||||
info.happ_crypto_link = encrypted
|
||||
return info
|
||||
|
||||
async def get_subscription_by_short_uuid(self, short_uuid: str) -> str:
|
||||
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}") as response:
|
||||
@@ -532,10 +795,148 @@ class RemnaWaveAPI:
|
||||
return response['response']
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||||
response = await self._make_request('GET', '/api/nodes/usage/realtime')
|
||||
return await self.get_bandwidth_stats_nodes_realtime()
|
||||
|
||||
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]:
|
||||
return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date)
|
||||
|
||||
# ============== Bandwidth Stats API ==============
|
||||
|
||||
async def get_bandwidth_stats_nodes(self, start_date: str, end_date: str) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params)
|
||||
return response['response']
|
||||
|
||||
|
||||
|
||||
async def get_bandwidth_stats_nodes_realtime(self) -> List[Dict[str, Any]]:
|
||||
response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime')
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_node_users(
|
||||
self,
|
||||
node_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
top_users_limit: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date,
|
||||
'topUsersLimit': top_users_limit
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_node_users_legacy(
|
||||
self,
|
||||
node_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_user(
|
||||
self,
|
||||
user_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_user_legacy(
|
||||
self,
|
||||
user_uuid: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> Dict[str, Any]:
|
||||
params = {
|
||||
'start': start_date,
|
||||
'end': end_date
|
||||
}
|
||||
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}/legacy', params=params)
|
||||
return response
|
||||
|
||||
# ============== Subscription Page Configs API ==============
|
||||
|
||||
async def get_subscription_page_configs(self) -> List[SubscriptionPageConfig]:
|
||||
response = await self._make_request('GET', '/api/subscription-page-configs')
|
||||
configs_data = response['response'].get('configs', [])
|
||||
return [self._parse_subscription_page_config(c) for c in configs_data]
|
||||
|
||||
async def get_subscription_page_config(self, uuid: str) -> Optional[SubscriptionPageConfig]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/subscription-page-configs/{uuid}')
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def create_subscription_page_config(self, name: str) -> SubscriptionPageConfig:
|
||||
data = {'name': name}
|
||||
response = await self._make_request('POST', '/api/subscription-page-configs', data)
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
|
||||
async def update_subscription_page_config(
|
||||
self,
|
||||
uuid: str,
|
||||
name: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> SubscriptionPageConfig:
|
||||
data = {'uuid': uuid}
|
||||
if name is not None:
|
||||
data['name'] = name
|
||||
if config is not None:
|
||||
data['config'] = config
|
||||
response = await self._make_request('PATCH', '/api/subscription-page-configs', data)
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
|
||||
async def delete_subscription_page_config(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/subscription-page-configs/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
async def reorder_subscription_page_configs(self, items: List[Dict[str, Any]]) -> List[SubscriptionPageConfig]:
|
||||
data = {'items': items}
|
||||
response = await self._make_request('POST', '/api/subscription-page-configs/actions/reorder', data)
|
||||
configs_data = response['response'].get('configs', [])
|
||||
return [self._parse_subscription_page_config(c) for c in configs_data]
|
||||
|
||||
async def clone_subscription_page_config(self, clone_from_uuid: str) -> SubscriptionPageConfig:
|
||||
data = {'cloneFromUuid': clone_from_uuid}
|
||||
response = await self._make_request('POST', '/api/subscription-page-configs/actions/clone', data)
|
||||
return self._parse_subscription_page_config(response['response'])
|
||||
|
||||
async def get_subpage_config_by_short_uuid(self, short_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/subscriptions/subpage-config/{short_uuid}')
|
||||
return response.get('response')
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def _parse_subscription_page_config(self, data: Dict) -> SubscriptionPageConfig:
|
||||
"""Парсит данные конфигурации страницы подписки"""
|
||||
return SubscriptionPageConfig(
|
||||
uuid=data['uuid'],
|
||||
name=data['name'],
|
||||
view_position=data['viewPosition'],
|
||||
config=data.get('config')
|
||||
)
|
||||
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
|
||||
@@ -584,44 +985,94 @@ class RemnaWaveAPI:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> Optional[str]:
|
||||
try:
|
||||
data = {"linkToEncrypt": link_to_encrypt}
|
||||
response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data)
|
||||
return response.get('response', {}).get('encryptedLink')
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.warning(f"Не удалось зашифровать happ ссылку: {e.message}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка при шифровании happ ссылки: {e}")
|
||||
return None
|
||||
|
||||
async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser:
|
||||
if not user.happ_crypto_link and user.subscription_url:
|
||||
encrypted = await self.encrypt_happ_crypto_link(user.subscription_url)
|
||||
if encrypted:
|
||||
user.happ_crypto_link = encrypted
|
||||
return user
|
||||
|
||||
|
||||
def _parse_user_traffic(self, traffic_data: Optional[Dict]) -> Optional[UserTraffic]:
|
||||
"""Парсит данные трафика из нового формата API"""
|
||||
if not traffic_data:
|
||||
return None
|
||||
|
||||
return UserTraffic(
|
||||
used_traffic_bytes=int(traffic_data.get('usedTrafficBytes', 0)),
|
||||
lifetime_used_traffic_bytes=int(traffic_data.get('lifetimeUsedTrafficBytes', 0)),
|
||||
online_at=self._parse_optional_datetime(traffic_data.get('onlineAt')),
|
||||
first_connected_at=self._parse_optional_datetime(traffic_data.get('firstConnectedAt')),
|
||||
last_connected_node_uuid=traffic_data.get('lastConnectedNodeUuid')
|
||||
)
|
||||
|
||||
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
|
||||
happ_data = user_data.get('happ') or {}
|
||||
happ_link = happ_data.get('link') or happ_data.get('url')
|
||||
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
|
||||
|
||||
# Парсим userTraffic из нового формата API
|
||||
user_traffic = self._parse_user_traffic(user_data.get('userTraffic'))
|
||||
|
||||
# Получаем status с fallback на ACTIVE
|
||||
status_str = user_data.get('status') or 'ACTIVE'
|
||||
try:
|
||||
status = UserStatus(status_str)
|
||||
except ValueError:
|
||||
logger.warning(f"Неизвестный статус пользователя: {status_str}, используем ACTIVE")
|
||||
status = UserStatus.ACTIVE
|
||||
|
||||
# Получаем trafficLimitStrategy с fallback
|
||||
strategy_str = user_data.get('trafficLimitStrategy') or 'NO_RESET'
|
||||
try:
|
||||
traffic_strategy = TrafficLimitStrategy(strategy_str)
|
||||
except ValueError:
|
||||
logger.warning(f"Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET")
|
||||
traffic_strategy = TrafficLimitStrategy.NO_RESET
|
||||
|
||||
return RemnaWaveUser(
|
||||
uuid=user_data['uuid'],
|
||||
short_uuid=user_data['shortUuid'],
|
||||
username=user_data['username'],
|
||||
status=UserStatus(user_data['status']),
|
||||
used_traffic_bytes=int(user_data['usedTrafficBytes']),
|
||||
lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']),
|
||||
traffic_limit_bytes=user_data['trafficLimitBytes'],
|
||||
traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']),
|
||||
status=status,
|
||||
traffic_limit_bytes=user_data.get('trafficLimitBytes', 0),
|
||||
traffic_limit_strategy=traffic_strategy,
|
||||
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
|
||||
telegram_id=user_data.get('telegramId'),
|
||||
email=user_data.get('email'),
|
||||
hwid_device_limit=user_data.get('hwidDeviceLimit'),
|
||||
description=user_data.get('description'),
|
||||
tag=user_data.get('tag'),
|
||||
subscription_url=user_data['subscriptionUrl'],
|
||||
active_internal_squads=user_data['activeInternalSquads'],
|
||||
subscription_url=user_data.get('subscriptionUrl', ''),
|
||||
active_internal_squads=user_data.get('activeInternalSquads', []),
|
||||
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
|
||||
user_traffic=user_traffic,
|
||||
sub_last_user_agent=user_data.get('subLastUserAgent'),
|
||||
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
|
||||
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
|
||||
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
|
||||
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
|
||||
trojan_password=user_data.get('trojanPassword'),
|
||||
vless_uuid=user_data.get('vlessUuid'),
|
||||
ss_password=user_data.get('ssPassword'),
|
||||
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
happ_crypto_link=happ_crypto_link,
|
||||
external_squad_uuid=user_data.get('externalSquadUuid'),
|
||||
id=user_data.get('id')
|
||||
)
|
||||
|
||||
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
@@ -629,28 +1080,76 @@ class RemnaWaveAPI:
|
||||
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
return None
|
||||
|
||||
def _parse_inbound(self, inbound_data: Dict) -> RemnaWaveInbound:
|
||||
"""Парсит данные inbound"""
|
||||
return RemnaWaveInbound(
|
||||
uuid=inbound_data['uuid'],
|
||||
profile_uuid=inbound_data['profileUuid'],
|
||||
tag=inbound_data['tag'],
|
||||
type=inbound_data['type'],
|
||||
network=inbound_data.get('network'),
|
||||
security=inbound_data.get('security'),
|
||||
port=inbound_data.get('port'),
|
||||
raw_inbound=inbound_data.get('rawInbound')
|
||||
)
|
||||
|
||||
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
|
||||
info = squad_data.get('info', {})
|
||||
inbounds_raw = squad_data.get('inbounds', [])
|
||||
inbounds = [self._parse_inbound(ib) for ib in inbounds_raw] if inbounds_raw else []
|
||||
return RemnaWaveInternalSquad(
|
||||
uuid=squad_data['uuid'],
|
||||
name=squad_data['name'],
|
||||
members_count=squad_data['info']['membersCount'],
|
||||
inbounds_count=squad_data['info']['inboundsCount'],
|
||||
inbounds=squad_data['inbounds']
|
||||
members_count=info.get('membersCount', 0),
|
||||
inbounds_count=info.get('inboundsCount', 0),
|
||||
inbounds=inbounds,
|
||||
view_position=squad_data.get('viewPosition', 0),
|
||||
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
|
||||
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
|
||||
)
|
||||
|
||||
|
||||
def _parse_accessible_node(self, node_data: Dict) -> RemnaWaveAccessibleNode:
|
||||
"""Парсит данные доступной ноды для Internal Squad"""
|
||||
return RemnaWaveAccessibleNode(
|
||||
uuid=node_data['uuid'],
|
||||
node_name=node_data['nodeName'],
|
||||
country_code=node_data['countryCode'],
|
||||
config_profile_uuid=node_data['configProfileUuid'],
|
||||
config_profile_name=node_data['configProfileName'],
|
||||
active_inbounds=node_data.get('activeInbounds', [])
|
||||
)
|
||||
|
||||
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
|
||||
return RemnaWaveNode(
|
||||
uuid=node_data['uuid'],
|
||||
name=node_data['name'],
|
||||
address=node_data['address'],
|
||||
country_code=node_data['countryCode'],
|
||||
is_connected=node_data['isConnected'],
|
||||
is_disabled=node_data['isDisabled'],
|
||||
is_node_online=node_data['isNodeOnline'],
|
||||
is_xray_running=node_data['isXrayRunning'],
|
||||
country_code=node_data.get('countryCode', ''),
|
||||
is_connected=node_data.get('isConnected', False),
|
||||
is_disabled=node_data.get('isDisabled', False),
|
||||
users_online=node_data.get('usersOnline'),
|
||||
traffic_used_bytes=node_data.get('trafficUsedBytes'),
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes')
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes'),
|
||||
port=node_data.get('port'),
|
||||
is_connecting=node_data.get('isConnecting', False),
|
||||
xray_version=node_data.get('xrayVersion'),
|
||||
node_version=node_data.get('nodeVersion'),
|
||||
view_position=node_data.get('viewPosition', 0),
|
||||
tags=node_data.get('tags', []),
|
||||
# Новые поля API
|
||||
last_status_change=self._parse_optional_datetime(node_data.get('lastStatusChange')),
|
||||
last_status_message=node_data.get('lastStatusMessage'),
|
||||
xray_uptime=node_data.get('xrayUptime'),
|
||||
is_traffic_tracking_active=node_data.get('isTrafficTrackingActive', False),
|
||||
traffic_reset_day=node_data.get('trafficResetDay'),
|
||||
notify_percent=node_data.get('notifyPercent'),
|
||||
consumption_multiplier=node_data.get('consumptionMultiplier', 1.0),
|
||||
cpu_count=node_data.get('cpuCount'),
|
||||
cpu_model=node_data.get('cpuModel'),
|
||||
total_ram=node_data.get('totalRam'),
|
||||
created_at=self._parse_optional_datetime(node_data.get('createdAt')),
|
||||
updated_at=self._parse_optional_datetime(node_data.get('updatedAt')),
|
||||
provider_uuid=node_data.get('providerUuid')
|
||||
)
|
||||
|
||||
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
|
||||
|
||||
Vendored
+270
-127
@@ -1,12 +1,16 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
from typing import Optional, Dict, Any
|
||||
from ipaddress import (
|
||||
IPv4Address,
|
||||
IPv4Network,
|
||||
IPv6Address,
|
||||
IPv6Network,
|
||||
ip_address,
|
||||
ip_network,
|
||||
)
|
||||
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -15,155 +19,294 @@ from app.database.database import get_db
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
IPAddress = Union[IPv4Address, IPv6Address]
|
||||
IPNetwork = Union[IPv4Network, IPv6Network]
|
||||
|
||||
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("185.71.76.0/27"),
|
||||
ip_network("185.71.77.0/27"),
|
||||
ip_network("77.75.153.0/25"),
|
||||
ip_network("77.75.154.128/25"),
|
||||
ip_network("77.75.156.11/32"),
|
||||
ip_network("77.75.156.35/32"),
|
||||
ip_network("2a02:5180::/32"),
|
||||
)
|
||||
|
||||
|
||||
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("173.245.48.0/20"),
|
||||
ip_network("103.21.244.0/22"),
|
||||
ip_network("103.22.200.0/22"),
|
||||
ip_network("103.31.4.0/22"),
|
||||
ip_network("141.101.64.0/18"),
|
||||
ip_network("108.162.192.0/18"),
|
||||
ip_network("190.93.240.0/20"),
|
||||
ip_network("188.114.96.0/20"),
|
||||
ip_network("197.234.240.0/22"),
|
||||
ip_network("198.41.128.0/17"),
|
||||
ip_network("162.158.0.0/15"),
|
||||
ip_network("104.16.0.0/13"),
|
||||
ip_network("104.24.0.0/14"),
|
||||
ip_network("172.64.0.0/13"),
|
||||
ip_network("131.0.72.0/22"),
|
||||
ip_network("2400:cb00::/32"),
|
||||
ip_network("2606:4700::/32"),
|
||||
ip_network("2803:f800::/32"),
|
||||
ip_network("2405:b500::/32"),
|
||||
ip_network("2405:8100::/32"),
|
||||
ip_network("2a06:98c0::/29"),
|
||||
ip_network("2c0f:f248::/32"),
|
||||
)
|
||||
|
||||
|
||||
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
|
||||
"payment.succeeded",
|
||||
"payment.waiting_for_capture",
|
||||
"payment.canceled",
|
||||
)
|
||||
|
||||
|
||||
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
|
||||
candidates: List[str] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
for part in value.split(","):
|
||||
normalized = part.strip()
|
||||
if normalized:
|
||||
candidates.append(normalized)
|
||||
return candidates
|
||||
|
||||
|
||||
def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]:
|
||||
value = candidate.strip()
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value.startswith("[") and "]" in value:
|
||||
value = value[1:value.index("]")]
|
||||
|
||||
if "%" in value:
|
||||
value = value.split("%", 1)[0]
|
||||
|
||||
if value.count(":") == 1 and "." in value:
|
||||
host, _, port = value.rpartition(":")
|
||||
if port.isdigit():
|
||||
value = host
|
||||
|
||||
try:
|
||||
return ip_address(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool:
|
||||
if remote_ip is None:
|
||||
return True
|
||||
|
||||
if _is_trusted_proxy_ip(remote_ip):
|
||||
return True
|
||||
|
||||
return any(
|
||||
getattr(remote_ip, attribute)
|
||||
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
|
||||
)
|
||||
|
||||
|
||||
_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ())
|
||||
|
||||
|
||||
def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]:
|
||||
global _TRUSTED_PROXY_NETWORKS_CACHE
|
||||
|
||||
raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or ""
|
||||
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
|
||||
|
||||
if raw_value == cached_raw:
|
||||
return cached_networks
|
||||
|
||||
networks: List[IPNetwork] = []
|
||||
for part in raw_value.split(","):
|
||||
candidate = part.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
try:
|
||||
networks.append(ip_network(candidate, strict=False))
|
||||
except ValueError:
|
||||
logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate)
|
||||
|
||||
cached_networks = tuple(networks)
|
||||
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
|
||||
return cached_networks
|
||||
|
||||
|
||||
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
|
||||
if any(
|
||||
getattr(ip_object, attribute)
|
||||
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
|
||||
):
|
||||
return True
|
||||
|
||||
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
|
||||
return True
|
||||
|
||||
return any(ip_object in network for network in _get_trusted_proxy_networks())
|
||||
|
||||
|
||||
def resolve_yookassa_ip(
|
||||
candidates: Iterable[str],
|
||||
*,
|
||||
remote: Optional[str] = None,
|
||||
) -> Optional[IPAddress]:
|
||||
remote_ip = _parse_candidate_ip(remote) if remote else None
|
||||
|
||||
if (
|
||||
remote_ip is not None
|
||||
and remote_ip.is_global
|
||||
and not _is_trusted_proxy_ip(remote_ip)
|
||||
):
|
||||
return remote_ip
|
||||
|
||||
candidate_list = list(candidates)
|
||||
|
||||
if _should_trust_forwarded_headers(remote_ip):
|
||||
last_hop = remote_ip
|
||||
for candidate in reversed(candidate_list):
|
||||
ip_object = _parse_candidate_ip(candidate)
|
||||
if ip_object is not None:
|
||||
if last_hop is None or _is_trusted_proxy_ip(last_hop):
|
||||
if _is_trusted_proxy_ip(ip_object):
|
||||
last_hop = ip_object
|
||||
continue
|
||||
return ip_object
|
||||
break
|
||||
|
||||
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
|
||||
return last_hop
|
||||
|
||||
return remote_ip if remote_ip is not None else next(
|
||||
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
|
||||
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
|
||||
|
||||
|
||||
class YooKassaWebhookHandler:
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
|
||||
try:
|
||||
signature_parts = signature.strip().split(' ')
|
||||
|
||||
if len(signature_parts) < 4:
|
||||
logger.error(f"Неверный формат подписи YooKassa: {signature}")
|
||||
return False
|
||||
|
||||
version = signature_parts[0]
|
||||
payment_id = signature_parts[1]
|
||||
timestamp = signature_parts[2]
|
||||
received_signature = signature_parts[3]
|
||||
|
||||
if version != "v1":
|
||||
logger.error(f"Неподдерживаемая версия подписи: {version}")
|
||||
return False
|
||||
|
||||
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
|
||||
|
||||
|
||||
expected_signature_1 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
|
||||
|
||||
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
|
||||
expected_signature_2 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_2.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
|
||||
|
||||
signed_payload_3 = f"{timestamp}.{body}"
|
||||
expected_signature_3 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_3.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
|
||||
|
||||
logger.debug(f"Получена подпись: {received_signature}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
|
||||
|
||||
is_valid = (
|
||||
hmac.compare_digest(received_signature, expected_signature_1_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_2_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_3_b64)
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись YooKassa webhook проверена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
|
||||
return False
|
||||
|
||||
def __init__(self, payment_service: PaymentService):
|
||||
self.payment_service = payment_service
|
||||
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
try:
|
||||
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
|
||||
|
||||
header_ip_candidates = collect_yookassa_ip_candidates(
|
||||
request.headers.get("X-Forwarded-For"),
|
||||
request.headers.get("X-Real-IP"),
|
||||
request.headers.get("Cf-Connecting-Ip"),
|
||||
)
|
||||
client_ip = resolve_yookassa_ip(
|
||||
header_ip_candidates,
|
||||
remote=request.remote,
|
||||
)
|
||||
|
||||
if client_ip is None:
|
||||
logger.warning(
|
||||
"🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s",
|
||||
header_ip_candidates + ([request.remote] if request.remote else []),
|
||||
)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
if not is_yookassa_ip_allowed(client_ip):
|
||||
logger.warning(
|
||||
"🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)",
|
||||
client_ip,
|
||||
", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
|
||||
)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip)
|
||||
|
||||
body = await request.text()
|
||||
|
||||
|
||||
if not body:
|
||||
logger.warning("⚠️ Получен пустой webhook от YooKassa")
|
||||
return web.Response(status=400, text="Empty body")
|
||||
|
||||
|
||||
logger.info(f"📄 Body: {body}")
|
||||
|
||||
|
||||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||||
|
||||
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
|
||||
logger.info(f"🔐 Получена подпись: {signature}")
|
||||
|
||||
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
|
||||
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
|
||||
else:
|
||||
logger.info("✅ Подпись webhook проверена успешно")
|
||||
|
||||
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
|
||||
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
|
||||
|
||||
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
|
||||
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
||||
|
||||
else:
|
||||
logger.info("ℹ️ Проверка подписи отключена")
|
||||
|
||||
if signature:
|
||||
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
|
||||
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
||||
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
||||
|
||||
|
||||
event_type = webhook_data.get("event")
|
||||
if not event_type:
|
||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||
return web.Response(status=400, text="No event type")
|
||||
|
||||
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
|
||||
|
||||
# Извлекаем ID платежа из вебхука для предотвращения дублирования
|
||||
yookassa_payment_id = webhook_data.get("object", {}).get("id")
|
||||
if not yookassa_payment_id:
|
||||
logger.warning("⚠️ Webhook YooKassa без ID платежа")
|
||||
return web.Response(status=400, text="No payment id")
|
||||
|
||||
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
||||
from app.database.models import PaymentMethod
|
||||
from app.database.crud.transaction import get_transaction_by_external_id
|
||||
existing_transaction = None
|
||||
if yookassa_payment_id and hasattr(db, "execute"):
|
||||
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
|
||||
|
||||
if existing_transaction and event_type == "payment.succeeded":
|
||||
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
return web.Response(status=200, text="OK")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
return web.Response(status=500, text="Processing error")
|
||||
|
||||
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
|
||||
def setup_routes(self, app: web.Application) -> None:
|
||||
|
||||
|
||||
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
|
||||
app.router.add_post(webhook_path, self.handle_webhook)
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
|
||||
|
||||
|
||||
async def _get_handler(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
@@ -172,7 +315,7 @@ class YooKassaWebhookHandler:
|
||||
"path": request.path,
|
||||
"note": "Use POST method for actual webhooks"
|
||||
})
|
||||
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
@@ -185,46 +328,46 @@ class YooKassaWebhookHandler:
|
||||
|
||||
|
||||
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
|
||||
|
||||
app = web.Application()
|
||||
|
||||
|
||||
webhook_handler = YooKassaWebhookHandler(payment_service)
|
||||
webhook_handler.setup_routes(app)
|
||||
|
||||
|
||||
async def health_check(request):
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"status": "ok",
|
||||
"service": "yookassa_webhook",
|
||||
"port": settings.YOOKASSA_WEBHOOK_PORT,
|
||||
"path": settings.YOOKASSA_WEBHOOK_PATH,
|
||||
"enabled": settings.is_yookassa_enabled()
|
||||
})
|
||||
|
||||
|
||||
app.router.add_get("/health", health_check)
|
||||
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
|
||||
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
app = create_yookassa_webhook_app(payment_service)
|
||||
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.YOOKASSA_WEBHOOK_HOST,
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
|
||||
await site.start()
|
||||
|
||||
|
||||
logger.info(
|
||||
"✅ YooKassa webhook сервер запущен на %s:%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
@@ -236,7 +379,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
@@ -246,7 +389,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("✅ YooKassa webhook сервер остановлен")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
|
||||
raise
|
||||
raise
|
||||
@@ -1 +1,38 @@
|
||||
# Инициализация админских обработчиков
|
||||
# Инициализация админских обработчиков
|
||||
from . import (
|
||||
backup,
|
||||
blacklist,
|
||||
bot_configuration,
|
||||
bulk_ban,
|
||||
campaigns,
|
||||
daily_contests,
|
||||
contests,
|
||||
faq,
|
||||
main,
|
||||
maintenance,
|
||||
messages,
|
||||
monitoring,
|
||||
payments,
|
||||
polls,
|
||||
pricing,
|
||||
privacy_policy,
|
||||
promo_groups,
|
||||
promo_offers,
|
||||
promocodes,
|
||||
public_offer,
|
||||
referrals,
|
||||
remnawave,
|
||||
reports,
|
||||
rules,
|
||||
servers,
|
||||
statistics,
|
||||
subscriptions,
|
||||
support_settings,
|
||||
system_logs,
|
||||
tickets,
|
||||
trials,
|
||||
updates,
|
||||
user_messages,
|
||||
users,
|
||||
welcome_text,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Обработчики админ-панели для управления черным списком
|
||||
"""
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.states import BlacklistStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.keyboards.admin import get_admin_users_keyboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_blacklist_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""
|
||||
Показывает настройки черного списка
|
||||
"""
|
||||
logger.info(f"Вызван обработчик show_blacklist_settings для пользователя {callback.from_user.id}")
|
||||
|
||||
is_enabled = blacklist_service.is_blacklist_check_enabled()
|
||||
github_url = blacklist_service.get_blacklist_github_url()
|
||||
blacklist_count = len(await blacklist_service.get_all_blacklisted_users())
|
||||
|
||||
status_text = "✅ Включена" if is_enabled else "❌ Отключена"
|
||||
url_text = github_url if github_url else "Не задан"
|
||||
|
||||
text = f"""
|
||||
🔐 <b>Настройки черного списка</b>
|
||||
|
||||
Статус: {status_text}
|
||||
URL к черному списку: <code>{url_text}</code>
|
||||
Количество записей: {blacklist_count}
|
||||
|
||||
Действия:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Обновить список" if is_enabled else "🔄 Обновить (откл.)",
|
||||
callback_data="admin_blacklist_update"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📋 Просмотреть список" if is_enabled else "📋 Просмотр (откл.)",
|
||||
callback_data="admin_blacklist_view"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ URL к GitHub" if not github_url else "✏️ Изменить URL",
|
||||
callback_data="admin_blacklist_set_url"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Включить" if not is_enabled else "❌ Отключить",
|
||||
callback_data="admin_blacklist_toggle"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад к пользователям",
|
||||
callback_data="admin_users"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_blacklist(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""
|
||||
Переключает статус проверки черного списка
|
||||
"""
|
||||
# Текущая реализация использует настройки из .env
|
||||
# Для полной реализации нужно будет создать сервис настроек
|
||||
is_enabled = blacklist_service.is_blacklist_check_enabled()
|
||||
|
||||
# В реальной реализации нужно будет изменить настройку в базе данных
|
||||
# или в системе настроек, но сейчас просто покажем статус
|
||||
new_status = not is_enabled
|
||||
status_text = "включена" if new_status else "отключена"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"Статус проверки черного списка: {status_text}\n\n"
|
||||
f"Для изменения статуса проверки черного списка измените значение\n"
|
||||
f"<code>BLACKLIST_CHECK_ENABLED</code> в файле <code>.env</code>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Обновить статус",
|
||||
callback_data="admin_blacklist_settings"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data="admin_blacklist_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def update_blacklist(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""
|
||||
Обновляет черный список из GitHub
|
||||
"""
|
||||
success, message = await blacklist_service.force_update_blacklist()
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ {message}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="📋 Просмотреть список",
|
||||
callback_data="admin_blacklist_view"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Ручное обновление",
|
||||
callback_data="admin_blacklist_update"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data="admin_blacklist_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"❌ Ошибка обновления: {message}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Повторить",
|
||||
callback_data="admin_blacklist_update"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data="admin_blacklist_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_blacklist_users(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""
|
||||
Показывает список пользователей в черном списке
|
||||
"""
|
||||
blacklist_users = await blacklist_service.get_all_blacklisted_users()
|
||||
|
||||
if not blacklist_users:
|
||||
text = "Черный список пуст"
|
||||
else:
|
||||
text = f"🔐 <b>Черный список ({len(blacklist_users)} записей)</b>\n\n"
|
||||
|
||||
# Показываем первые 20 записей
|
||||
for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1):
|
||||
text += f"{i}. <code>{tg_id}</code> {username or ''} — {reason}\n"
|
||||
|
||||
if len(blacklist_users) > 20:
|
||||
text += f"\n... и еще {len(blacklist_users) - 20} записей"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Обновить",
|
||||
callback_data="admin_blacklist_view"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data="admin_blacklist_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_set_blacklist_url(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""
|
||||
Начинает процесс установки URL к черному списку
|
||||
"""
|
||||
current_url = blacklist_service.get_blacklist_github_url() or "не задан"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"Введите новый URL к файлу черного списка на GitHub\n\n"
|
||||
f"Текущий URL: {current_url}\n\n"
|
||||
f"Пример: https://raw.githubusercontent.com/username/repository/main/blacklist.txt\n\n"
|
||||
f"Для отмены используйте команду /cancel",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data="admin_blacklist_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(BlacklistStates.waiting_for_blacklist_url)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_blacklist_url(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""
|
||||
Обрабатывает введенный URL к черному списку
|
||||
"""
|
||||
# Обрабатываем сообщение только если бот ожидает ввод URL
|
||||
if await state.get_state() != BlacklistStates.waiting_for_blacklist_url.state:
|
||||
return
|
||||
|
||||
url = message.text.strip()
|
||||
|
||||
# В реальной реализации нужно сохранить URL в систему настроек
|
||||
# В текущей реализации просто выводим сообщение
|
||||
if url.lower() in ['/cancel', 'отмена', 'cancel']:
|
||||
await message.answer(
|
||||
"Настройка URL отменена",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔐 Настройки черного списка",
|
||||
callback_data="admin_blacklist_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, что URL выглядит корректно
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
await message.answer(
|
||||
"❌ Некорректный URL. URL должен начинаться с http:// или https://",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔐 Настройки черного списка",
|
||||
callback_data="admin_blacklist_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
# В реальной системе здесь нужно сохранить URL в базу данных настроек
|
||||
# или в систему конфигурации
|
||||
|
||||
await message.answer(
|
||||
f"✅ URL к черному списку установлен:\n<code>{url}</code>\n\n"
|
||||
f"Для применения изменений перезапустите бота или измените значение\n"
|
||||
f"<code>BLACKLIST_GITHUB_URL</code> в файле <code>.env</code>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Обновить список",
|
||||
callback_data="admin_blacklist_update"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔐 Настройки черного списка",
|
||||
callback_data="admin_blacklist_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_blacklist_handlers(dp):
|
||||
"""
|
||||
Регистрация обработчиков черного списка
|
||||
"""
|
||||
# Обработчик показа настроек черного списка
|
||||
# Этот обработчик нужно будет вызывать из меню пользователей или отдельно
|
||||
dp.callback_query.register(
|
||||
show_blacklist_settings,
|
||||
lambda c: c.data == "admin_blacklist_settings"
|
||||
)
|
||||
|
||||
# Обработчики для взаимодействия с черным списком
|
||||
dp.callback_query.register(
|
||||
toggle_blacklist,
|
||||
lambda c: c.data == "admin_blacklist_toggle"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
update_blacklist,
|
||||
lambda c: c.data == "admin_blacklist_update"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_blacklist_users,
|
||||
lambda c: c.data == "admin_blacklist_view"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_set_blacklist_url,
|
||||
lambda c: c.data == "admin_blacklist_set_url"
|
||||
)
|
||||
|
||||
# Обработчик сообщений для установки URL (работает только в нужном состоянии)
|
||||
dp.message.register(
|
||||
process_blacklist_url,
|
||||
StateFilter(BlacklistStates.waiting_for_blacklist_url)
|
||||
)
|
||||
@@ -13,6 +13,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SystemSetting, User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_id,
|
||||
get_server_squad_by_uuid,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.config import settings
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
@@ -30,13 +35,23 @@ from app.external.telegram_stars import TelegramStarsService
|
||||
|
||||
CATEGORY_PAGE_SIZE = 10
|
||||
SETTINGS_PAGE_SIZE = 8
|
||||
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
|
||||
|
||||
CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"core": {
|
||||
"title": "🤖 Основные",
|
||||
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
|
||||
"icon": "🤖",
|
||||
"categories": ("CORE", "CHANNEL"),
|
||||
"categories": (
|
||||
"CORE",
|
||||
"CHANNEL",
|
||||
"TIMEZONE",
|
||||
"DATABASE",
|
||||
"POSTGRES",
|
||||
"SQLITE",
|
||||
"REDIS",
|
||||
"REMNAWAVE",
|
||||
),
|
||||
},
|
||||
"support": {
|
||||
"title": "💬 Поддержка",
|
||||
@@ -46,24 +61,35 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"payments": {
|
||||
"title": "💳 Платежные системы",
|
||||
"description": "YooKassa, CryptoBot, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
"PAYMENT_VERIFICATION",
|
||||
"YOOKASSA",
|
||||
"CRYPTOBOT",
|
||||
"HELEKET",
|
||||
"MULENPAY",
|
||||
"PAL24",
|
||||
"WATA",
|
||||
"PLATEGA",
|
||||
"TRIBUTE",
|
||||
"TELEGRAM",
|
||||
),
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "📅 Подписки и цены",
|
||||
"description": "Тарифы, периоды, лимиты трафика и автопродление.",
|
||||
"description": "Тарифы, простая покупка, периоды, лимиты трафика и автопродление.",
|
||||
"icon": "📅",
|
||||
"categories": ("SUBSCRIPTIONS_CORE", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "AUTOPAY"),
|
||||
"categories": (
|
||||
"SUBSCRIPTIONS_CORE",
|
||||
"SIMPLE_SUBSCRIPTION",
|
||||
"PERIODS",
|
||||
"SUBSCRIPTION_PRICES",
|
||||
"TRAFFIC",
|
||||
"TRAFFIC_PACKAGES",
|
||||
"AUTOPAY",
|
||||
),
|
||||
},
|
||||
"trial": {
|
||||
"title": "🎁 Пробный период",
|
||||
@@ -99,18 +125,6 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"ADDITIONAL",
|
||||
),
|
||||
},
|
||||
"database": {
|
||||
"title": "💾 База данных",
|
||||
"description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
|
||||
"icon": "💾",
|
||||
"categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
|
||||
},
|
||||
"remnawave": {
|
||||
"title": "🌐 RemnaWave API",
|
||||
"description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
|
||||
"icon": "🌐",
|
||||
"categories": ("REMNAWAVE",),
|
||||
},
|
||||
"server": {
|
||||
"title": "📊 Статус серверов",
|
||||
"description": "Мониторинг серверов, SLA и внешние метрики.",
|
||||
@@ -127,13 +141,14 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"title": "⚡ Расширенные",
|
||||
"description": "Web API, webhook, логирование, модерация и режим отладки.",
|
||||
"icon": "⚡",
|
||||
"categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
|
||||
},
|
||||
"external_admin": {
|
||||
"title": "🛡️ Внешняя админка",
|
||||
"description": "Токен, по которому внешняя админка проверяет запросы.",
|
||||
"icon": "🛡️",
|
||||
"categories": ("EXTERNAL_ADMIN",),
|
||||
"categories": (
|
||||
"WEB_API",
|
||||
"WEBHOOK",
|
||||
"LOG",
|
||||
"MODERATION",
|
||||
"DEBUG",
|
||||
"EXTERNAL_ADMIN",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -146,12 +161,9 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
|
||||
"referral",
|
||||
"notifications",
|
||||
"interface",
|
||||
"database",
|
||||
"remnawave",
|
||||
"server",
|
||||
"maintenance",
|
||||
"advanced",
|
||||
"external_admin",
|
||||
)
|
||||
|
||||
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
|
||||
@@ -242,6 +254,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
payment_statuses = {
|
||||
"YooKassa": settings.is_yookassa_enabled(),
|
||||
"CryptoBot": settings.is_cryptobot_enabled(),
|
||||
"Platega": settings.is_platega_enabled(),
|
||||
"MulenPay": settings.is_mulenpay_enabled(),
|
||||
"PAL24": settings.is_pal24_enabled(),
|
||||
"Tribute": settings.TRIBUTE_ENABLED,
|
||||
@@ -298,7 +311,6 @@ 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.get_referred_user_reward_kopeks()
|
||||
)
|
||||
return ("🟢", "Программа активна") if active else ("⚪", "Бонусы не заданы")
|
||||
|
||||
@@ -1417,6 +1429,16 @@ def _build_setting_keyboard(
|
||||
for chunk in _chunk(choice_buttons, 2):
|
||||
rows.append(list(chunk))
|
||||
|
||||
if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID" and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🌍 Выбрать сквад",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{callback_token}:1"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
if definition.python_type is bool and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
@@ -1645,6 +1667,217 @@ async def show_bot_config_category(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_simple_subscription_squad_selector(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
parts = callback.data.split(":", 5)
|
||||
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
|
||||
try:
|
||||
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
|
||||
except ValueError:
|
||||
category_page = 1
|
||||
try:
|
||||
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
|
||||
except ValueError:
|
||||
settings_page = 1
|
||||
token = parts[4] if len(parts) > 4 else ""
|
||||
|
||||
try:
|
||||
key = bot_configuration_service.resolve_callback_token(token)
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
if key != "SIMPLE_SUBSCRIPTION_SQUAD_UUID":
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
page = max(1, int(parts[5])) if len(parts) > 5 else 1
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
limit = SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE
|
||||
squads, total_count = await get_all_server_squads(
|
||||
db,
|
||||
available_only=False,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
total_count = total_count or 0
|
||||
total_pages = max(1, math.ceil(total_count / limit)) if total_count else 1
|
||||
if total_count and page > total_pages:
|
||||
page = total_pages
|
||||
squads, total_count = await get_all_server_squads(
|
||||
db,
|
||||
available_only=False,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
current_uuid = bot_configuration_service.get_current_value(key) or ""
|
||||
current_display = "Любой доступный"
|
||||
|
||||
if current_uuid:
|
||||
selected_server = next((srv for srv in squads if srv.squad_uuid == current_uuid), None)
|
||||
if not selected_server:
|
||||
selected_server = await get_server_squad_by_uuid(db, current_uuid)
|
||||
if selected_server:
|
||||
current_display = selected_server.display_name
|
||||
else:
|
||||
current_display = current_uuid
|
||||
|
||||
lines = [
|
||||
"🌍 <b>Выберите сквад для простой покупки</b>",
|
||||
"",
|
||||
f"Текущий выбор: {html.escape(current_display)}" if current_display else "Текущий выбор: —",
|
||||
"",
|
||||
]
|
||||
|
||||
if total_count == 0:
|
||||
lines.append("❌ Доступные сервера не найдены.")
|
||||
else:
|
||||
lines.append("Выберите сервер из списка ниже.")
|
||||
if total_pages > 1:
|
||||
lines.append(f"Страница {page}/{total_pages}")
|
||||
|
||||
text = "\n".join(lines)
|
||||
|
||||
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
|
||||
|
||||
for server in squads:
|
||||
status_icon = (
|
||||
"✅" if server.squad_uuid == current_uuid else ("🟢" if server.is_available else "🔒")
|
||||
)
|
||||
label_parts = [status_icon, server.display_name]
|
||||
if server.country_code:
|
||||
label_parts.append(f"({server.country_code.upper()})")
|
||||
if isinstance(server.price_kopeks, int) and server.price_kopeks > 0:
|
||||
try:
|
||||
label_parts.append(f"— {settings.format_price(server.price_kopeks)}")
|
||||
except Exception:
|
||||
pass
|
||||
label = " ".join(label_parts)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad_select:{group_key}:{category_page}:{settings_page}:{token}:{server.id}:{page}"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: List[types.InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page - 1}"
|
||||
),
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=(
|
||||
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page + 1}"
|
||||
),
|
||||
)
|
||||
)
|
||||
if nav_row:
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=(
|
||||
f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_simple_subscription_squad(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
parts = callback.data.split(":", 6)
|
||||
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
|
||||
try:
|
||||
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
|
||||
except ValueError:
|
||||
category_page = 1
|
||||
try:
|
||||
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
|
||||
except ValueError:
|
||||
settings_page = 1
|
||||
token = parts[4] if len(parts) > 4 else ""
|
||||
try:
|
||||
server_id = int(parts[5]) if len(parts) > 5 else None
|
||||
except ValueError:
|
||||
server_id = None
|
||||
|
||||
if server_id is None:
|
||||
await callback.answer("Не удалось определить сервер", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
key = bot_configuration_service.resolve_callback_token(token)
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
await callback.answer("Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, server.squad_uuid)
|
||||
except ReadOnlySettingError:
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page)
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await _store_setting_context(
|
||||
state,
|
||||
key=key,
|
||||
group_key=group_key,
|
||||
category_page=category_page,
|
||||
settings_page=settings_page,
|
||||
)
|
||||
await callback.answer("Сквад выбран")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def test_remnawave_connection(
|
||||
@@ -1751,7 +1984,7 @@ async def test_payment_provider(
|
||||
return
|
||||
|
||||
amount_kopeks = 10 * 100
|
||||
description = settings.get_balance_payment_description(amount_kopeks)
|
||||
description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
|
||||
payment_result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -2519,6 +2752,14 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
test_payment_provider,
|
||||
F.data.startswith("botcfg_test_payment:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
select_simple_subscription_squad,
|
||||
F.data.startswith("botcfg_simple_squad_select:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_simple_subscription_squad_selector,
|
||||
F.data.startswith("botcfg_simple_squad:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_bot_config_setting,
|
||||
F.data.startswith("botcfg_setting:"),
|
||||
@@ -2557,4 +2798,3 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
handle_import_message,
|
||||
BotConfigStates.waiting_for_import_file,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Обработчики команд для массовой блокировки пользователей
|
||||
"""
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.bulk_ban_service import bulk_ban_service
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.keyboards.admin import get_admin_users_keyboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_bulk_ban_process(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""
|
||||
Начало процесса массовой блокировки пользователей
|
||||
"""
|
||||
await callback.message.edit_text(
|
||||
"🛑 <b>Массовая блокировка пользователей</b>\n\n"
|
||||
"Введите список Telegram ID для блокировки.\n\n"
|
||||
"<b>Форматы ввода:</b>\n"
|
||||
"• По одному ID на строку\n"
|
||||
"• Через запятую\n"
|
||||
"• Через пробел\n\n"
|
||||
"Пример:\n"
|
||||
"<code>123456789\n"
|
||||
"987654321\n"
|
||||
"111222333</code>\n\n"
|
||||
"Или:\n"
|
||||
"<code>123456789, 987654321, 111222333</code>\n\n"
|
||||
"Для отмены используйте команду /cancel",
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_bulk_ban_list)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_bulk_ban_list(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""
|
||||
Обработка списка Telegram ID и выполнение массовой блокировки
|
||||
"""
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
"❌ Отправьте текстовое сообщение со списком Telegram ID",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
input_text = message.text.strip()
|
||||
|
||||
if not input_text:
|
||||
await message.answer(
|
||||
"❌ Введите корректный список Telegram ID",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
# Парсим ID из текста
|
||||
try:
|
||||
telegram_ids = await bulk_ban_service.parse_telegram_ids_from_text(input_text)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка парсинга Telegram ID: {e}")
|
||||
await message.answer(
|
||||
"❌ Ошибка при обработке списка ID. Проверьте формат ввода.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
if not telegram_ids:
|
||||
await message.answer(
|
||||
"❌ Не найдено корректных Telegram ID в списке",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
if len(telegram_ids) > 1000: # Ограничение на количество ID за раз
|
||||
await message.answer(
|
||||
f"❌ Слишком много ID в списке ({len(telegram_ids)}). Максимум: 1000",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
# Выполняем массовую блокировку
|
||||
try:
|
||||
successfully_banned, not_found, error_ids = await bulk_ban_service.ban_users_by_telegram_ids(
|
||||
db=db,
|
||||
admin_user_id=db_user.id,
|
||||
telegram_ids=telegram_ids,
|
||||
reason="Массовая блокировка администратором",
|
||||
bot=message.bot,
|
||||
notify_admin=True,
|
||||
admin_name=db_user.full_name
|
||||
)
|
||||
|
||||
# Подготавливаем сообщение с результатами
|
||||
result_text = f"✅ <b>Массовая блокировка завершена</b>\n\n"
|
||||
result_text += f"📊 <b>Результаты:</b>\n"
|
||||
result_text += f"✅ Успешно заблокировано: {successfully_banned}\n"
|
||||
result_text += f"❌ Не найдено: {not_found}\n"
|
||||
result_text += f"💥 Ошибок: {len(error_ids)}\n\n"
|
||||
result_text += f"📈 Всего обработано: {len(telegram_ids)}"
|
||||
|
||||
if successfully_banned > 0:
|
||||
result_text += f"\n🎯 Процент успеха: {round((successfully_banned/len(telegram_ids))*100, 1)}%"
|
||||
|
||||
# Добавляем информацию об ошибках, если есть
|
||||
if error_ids:
|
||||
result_text += f"\n\n⚠️ <b>Telegram ID с ошибками:</b>\n"
|
||||
result_text += f"<code>{', '.join(map(str, error_ids[:10]))}</code>" # Показываем первые 10
|
||||
if len(error_ids) > 10:
|
||||
result_text += f" и еще {len(error_ids) - 10}..."
|
||||
|
||||
await message.answer(
|
||||
result_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👥 К пользователям", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при выполнении массовой блокировки: {e}")
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при выполнении массовой блокировки",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_bulk_ban_handlers(dp):
|
||||
"""
|
||||
Регистрация обработчиков команд для массовой блокировки
|
||||
"""
|
||||
# Обработчик команды начала массовой блокировки
|
||||
dp.callback_query.register(
|
||||
start_bulk_ban_process,
|
||||
lambda c: c.data == "admin_bulk_ban_start"
|
||||
)
|
||||
|
||||
# Обработчик текстового сообщения с ID для блокировки
|
||||
dp.message.register(
|
||||
process_bulk_ban_list,
|
||||
AdminStates.waiting_for_bulk_ban_list
|
||||
)
|
||||
@@ -46,6 +46,9 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
bonus_info = f"💰 Бонус на баланс: <b>{bonus_text}</b>"
|
||||
else:
|
||||
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
|
||||
device_limit = campaign.subscription_device_limit
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
bonus_info = (
|
||||
"📱 Подписка: <b>{days} д.</b>\n"
|
||||
"🌐 Трафик: <b>{traffic}</b>\n"
|
||||
@@ -53,7 +56,7 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
).format(
|
||||
days=campaign.subscription_duration_days or 0,
|
||||
traffic=traffic_text,
|
||||
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
devices=device_limit,
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -935,7 +938,9 @@ async def start_edit_campaign_subscription_devices(
|
||||
campaign_edit_message_is_caption=is_caption,
|
||||
)
|
||||
|
||||
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
current_devices = campaign.subscription_device_limit
|
||||
if current_devices is None:
|
||||
current_devices = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
await callback.message.edit_text(
|
||||
(
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone, time
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral_contest import (
|
||||
create_referral_contest,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_referral_contest,
|
||||
get_referral_contests_count,
|
||||
list_referral_contests,
|
||||
toggle_referral_contest,
|
||||
update_referral_contest,
|
||||
delete_referral_contest,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_contests_keyboard,
|
||||
get_admin_contests_root_keyboard,
|
||||
get_admin_pagination_keyboard,
|
||||
get_contest_mode_keyboard,
|
||||
get_referral_contest_manage_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAGE_SIZE = 5
|
||||
|
||||
|
||||
def _ensure_timezone(tz_name: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str:
|
||||
start_local = contest.start_at if contest.start_at.tzinfo else contest.start_at.replace(tzinfo=timezone.utc)
|
||||
end_local = contest.end_at if contest.end_at.tzinfo else contest.end_at.replace(tzinfo=timezone.utc)
|
||||
start_local = start_local.astimezone(tz)
|
||||
end_local = end_local.astimezone(tz)
|
||||
|
||||
status = texts.t("ADMIN_CONTEST_STATUS_ACTIVE", "🟢 Активен") if contest.is_active else texts.t(
|
||||
"ADMIN_CONTEST_STATUS_INACTIVE", "⚪️ Выключен"
|
||||
)
|
||||
|
||||
period = (
|
||||
f"{start_local.strftime('%d.%m %H:%M')} — "
|
||||
f"{end_local.strftime('%d.%m %H:%M')} ({tz.key})"
|
||||
)
|
||||
|
||||
summary_time = contest.daily_summary_time.strftime("%H:%M") if contest.daily_summary_time else "12:00"
|
||||
summary_times = contest.daily_summary_times or summary_time
|
||||
parts = [
|
||||
f"{status}",
|
||||
f"Период: <b>{period}</b>",
|
||||
f"Дневная сводка: <b>{summary_times}</b>",
|
||||
]
|
||||
if contest.prize_text:
|
||||
parts.append(texts.t("ADMIN_CONTEST_PRIZE", "Приз: {prize}").format(prize=contest.prize_text))
|
||||
if contest.last_daily_summary_date:
|
||||
parts.append(
|
||||
texts.t("ADMIN_CONTEST_LAST_DAILY", "Последняя сводка: {date}").format(
|
||||
date=contest.last_daily_summary_date.strftime("%d.%m")
|
||||
)
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _parse_local_datetime(value: str, tz: ZoneInfo) -> datetime | None:
|
||||
try:
|
||||
dt = datetime.strptime(value.strip(), "%d.%m.%Y %H:%M")
|
||||
except ValueError:
|
||||
return None
|
||||
return dt.replace(tzinfo=tz)
|
||||
|
||||
|
||||
def _parse_time(value: str):
|
||||
try:
|
||||
return datetime.strptime(value.strip(), "%H:%M").time()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_times(value: str) -> list[time]:
|
||||
times: list[time] = []
|
||||
for part in value.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
parsed = _parse_time(part)
|
||||
if parsed:
|
||||
times.append(parsed)
|
||||
return times
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_contests_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTESTS_DISABLED",
|
||||
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
|
||||
),
|
||||
reply_markup=get_admin_contests_root_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
|
||||
reply_markup=get_admin_contests_root_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_contests_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
|
||||
reply_markup=get_admin_contests_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_contests(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t(
|
||||
"ADMIN_CONTESTS_DISABLED",
|
||||
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
page = 1
|
||||
if callback.data.startswith("admin_contests_list_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
page = 1
|
||||
|
||||
total = await get_referral_contests_count(db)
|
||||
total_pages = max(1, math.ceil(total / PAGE_SIZE))
|
||||
page = max(1, min(page, total_pages))
|
||||
offset = (page - 1) * PAGE_SIZE
|
||||
|
||||
contests = await list_referral_contests(db, limit=PAGE_SIZE, offset=offset)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
lines = [texts.t("ADMIN_CONTESTS_LIST_HEADER", "🏆 <b>Конкурсы</b>\n")]
|
||||
|
||||
if not contests:
|
||||
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
|
||||
else:
|
||||
for contest in contests:
|
||||
lines.append(f"• <b>{contest.title}</b> (#{contest.id})")
|
||||
contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
|
||||
lines.append(_format_contest_summary(contest, texts, contest_tz))
|
||||
lines.append("")
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
for contest in contests:
|
||||
title = contest.title if len(contest.title) <= 25 else contest.title[:22] + "..."
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"🔎 {title}",
|
||||
callback_data=f"admin_contest_view_{contest.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
pagination = get_admin_pagination_keyboard(
|
||||
page,
|
||||
total_pages,
|
||||
"admin_contests_list",
|
||||
back_callback="admin_contests",
|
||||
language=db_user.language,
|
||||
)
|
||||
keyboard_rows.extend(pagination.inline_keyboard)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_contest_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not contest:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
|
||||
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
|
||||
total_events = await get_contest_events_count(db, contest.id)
|
||||
|
||||
lines = [
|
||||
f"🏆 <b>{contest.title}</b>",
|
||||
_format_contest_summary(contest, texts, tz),
|
||||
texts.t("ADMIN_CONTEST_TOTAL_EVENTS", "Зачётов: <b>{count}</b>").format(count=total_events),
|
||||
]
|
||||
|
||||
if contest.description:
|
||||
lines.append("")
|
||||
lines.append(contest.description)
|
||||
|
||||
if leaderboard:
|
||||
lines.append("")
|
||||
lines.append(texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"))
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
lines.append(f"{idx}. {user.full_name} — {score}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest.id,
|
||||
is_active=contest.is_active,
|
||||
can_delete=(
|
||||
not contest.is_active
|
||||
and (contest.end_at.replace(tzinfo=timezone.utc) if contest.end_at.tzinfo is None else contest.end_at)
|
||||
< datetime.now(timezone.utc)
|
||||
),
|
||||
language=db_user.language,
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
if not contest:
|
||||
await callback.answer("Конкурс не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await toggle_referral_contest(db, contest, not contest.is_active)
|
||||
await show_contest_details(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_summary_times(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
await state.set_state(AdminStates.editing_referral_contest_summary_times)
|
||||
await state.update_data(contest_id=contest_id)
|
||||
kb = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_contest_view_{contest_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_DAILY_TIME",
|
||||
"Во сколько отправлять ежедневные итоги? Формат ЧЧ:ММ или несколько через запятую (12:00,18:00).",
|
||||
),
|
||||
reply_markup=kb,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_summary_times(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
contest_id = data.get("contest_id")
|
||||
if not contest_id:
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
times = _parse_times(message.text or "")
|
||||
summary_time = times[0] if times else _parse_time(message.text or "")
|
||||
if not summary_time:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00")
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
contest = await get_referral_contest(db, int(contest_id))
|
||||
if not contest:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_referral_contest(
|
||||
db,
|
||||
contest,
|
||||
daily_summary_time=summary_time,
|
||||
daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None,
|
||||
)
|
||||
|
||||
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
if contest.is_active or contest.end_at > now_utc:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_CONTEST_DELETE_RESTRICT", "Удалять можно только завершённые конкурсы."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await delete_referral_contest(db, contest)
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_DELETED", "Конкурс удалён."), show_alert=True)
|
||||
await list_contests(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_leaderboard(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not contest:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
|
||||
if not leaderboard:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_EMPTY_LEADERBOARD", "Пока нет участников."), show_alert=True)
|
||||
return
|
||||
|
||||
lines = [
|
||||
texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"),
|
||||
]
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
lines.append(f"{idx}. {user.full_name} ({user.telegram_id}) — {score}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest_id, is_active=contest.is_active, language=db_user.language
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_contest_creation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await state.set_state(AdminStates.creating_referral_contest_mode)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_MODE_PROMPT",
|
||||
"Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
|
||||
),
|
||||
reply_markup=get_contest_mode_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_contest_mode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mode = "referral_paid" if callback.data == "admin_contest_mode_paid" else "referral_registered"
|
||||
await state.update_data(contest_type=mode)
|
||||
await state.set_state(AdminStates.creating_referral_contest_title)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTEST_ENTER_TITLE", "Введите название конкурса:"),
|
||||
reply_markup=None,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_title(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
title = message.text.strip()
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await state.update_data(title=title)
|
||||
await state.set_state(AdminStates.creating_referral_contest_description)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_ENTER_DESCRIPTION", "Опишите конкурс (или отправьте '-' чтобы пропустить):")
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_description(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
description = message.text.strip()
|
||||
if description in {"-", "skip", "пропустить"}:
|
||||
description = None
|
||||
|
||||
await state.update_data(description=description)
|
||||
await state.set_state(AdminStates.creating_referral_contest_prize)
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_ENTER_PRIZE", "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):")
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_prize(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
prize = message.text.strip()
|
||||
if prize in {"-", "skip", "пропустить"}:
|
||||
prize = None
|
||||
|
||||
await state.update_data(prize=prize)
|
||||
await state.set_state(AdminStates.creating_referral_contest_start)
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_START",
|
||||
"Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_start_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
tz = _ensure_timezone(settings.TIMEZONE)
|
||||
start_dt = _parse_local_datetime(message.text, tz)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not start_dt:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(start_at=start_dt.isoformat())
|
||||
await state.set_state(AdminStates.creating_referral_contest_end)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_END",
|
||||
"Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_end_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
tz = _ensure_timezone(settings.TIMEZONE)
|
||||
end_dt = _parse_local_datetime(message.text, tz)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not end_dt:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
start_raw = data.get("start_at")
|
||||
start_dt = datetime.fromisoformat(start_raw) if start_raw else None
|
||||
if start_dt and end_dt <= start_dt:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_END_BEFORE_START",
|
||||
"Дата окончания должна быть позже даты начала.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(end_at=end_dt.isoformat())
|
||||
await state.set_state(AdminStates.creating_referral_contest_time)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_DAILY_TIME",
|
||||
"Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def finalize_contest_creation(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
times = _parse_times(message.text or "")
|
||||
summary_time = times[0] if times else _parse_time(message.text)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not summary_time:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00")
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
tz = _ensure_timezone(settings.TIMEZONE)
|
||||
|
||||
start_at_raw = data.get("start_at")
|
||||
end_at_raw = data.get("end_at")
|
||||
if not start_at_raw or not end_at_raw:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату."))
|
||||
return
|
||||
|
||||
start_at = (
|
||||
datetime.fromisoformat(start_at_raw)
|
||||
.astimezone(timezone.utc)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
end_at = (
|
||||
datetime.fromisoformat(end_at_raw)
|
||||
.astimezone(timezone.utc)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
|
||||
contest_type = data.get("contest_type") or "referral_paid"
|
||||
|
||||
contest = await create_referral_contest(
|
||||
db,
|
||||
title=data.get("title"),
|
||||
description=data.get("description"),
|
||||
prize_text=data.get("prize"),
|
||||
contest_type=contest_type,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
daily_summary_time=summary_time,
|
||||
daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None,
|
||||
timezone_name=tz.key,
|
||||
created_by=db_user.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_CREATED", "Конкурс создан!"),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest.id,
|
||||
is_active=contest.is_active,
|
||||
language=db_user.language,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_detailed_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
if not contest:
|
||||
await callback.answer("Конкурс не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
|
||||
# Общее сообщение с основной статистикой
|
||||
general_lines = [
|
||||
"📈 <b>Статистика конкурса</b>",
|
||||
f"🏆 {contest.title}",
|
||||
"",
|
||||
f"👥 Участников: <b>{stats['total_participants']}</b>",
|
||||
f"📨 Приглашено рефералов: <b>{stats['total_invited']}</b>",
|
||||
f"💰 Оплатили подписок: <b>{stats['total_paid_amount'] // 100} руб.</b>",
|
||||
f"❌ Не оплатили: <b>{stats['total_unpaid']}</b>",
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(general_lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest_id, is_active=contest.is_active, language=db_user.language
|
||||
),
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_detailed_stats_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
contest_id: int = None,
|
||||
page: int = 1,
|
||||
stats: dict = None,
|
||||
):
|
||||
if contest_id is None or stats is None:
|
||||
# Парсим из callback.data: admin_contest_detailed_stats_page_{contest_id}_page_{page}
|
||||
parts = callback.data.split("_")
|
||||
contest_id = int(parts[5]) # contest_id после page
|
||||
page = int(parts[7]) # page после второго page
|
||||
|
||||
# Получаем stats если не переданы
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
|
||||
participants = stats['participants']
|
||||
total_participants = len(participants)
|
||||
PAGE_SIZE = 10
|
||||
total_pages = math.ceil(total_participants / PAGE_SIZE)
|
||||
|
||||
page = max(1, min(page, total_pages))
|
||||
offset = (page - 1) * PAGE_SIZE
|
||||
page_participants = participants[offset:offset + PAGE_SIZE]
|
||||
|
||||
lines = [f"📊 По участникам (страница {page}/{total_pages}):"]
|
||||
for p in page_participants:
|
||||
lines.extend([
|
||||
f"• <b>{p['full_name']}</b>",
|
||||
f" 📨 Приглашено: {p['total_referrals']}",
|
||||
f" 💰 Оплатили: {p['paid_referrals']}",
|
||||
f" ❌ Не оплатили: {p['unpaid_referrals']}",
|
||||
f" 💵 Сумма: {p['total_paid_amount'] // 100} руб.",
|
||||
"" # Пустая строка для разделения
|
||||
])
|
||||
|
||||
pagination = get_admin_pagination_keyboard(
|
||||
page,
|
||||
total_pages,
|
||||
f"admin_contest_detailed_stats_page_{contest_id}",
|
||||
back_callback=f"admin_contest_view_{contest_id}",
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=pagination,
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_contests_menu, F.data == "admin_contests")
|
||||
dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral")
|
||||
dp.callback_query.register(list_contests, F.data == "admin_contests_list")
|
||||
dp.callback_query.register(list_contests, F.data.startswith("admin_contests_list_page_"))
|
||||
dp.callback_query.register(show_contest_details, F.data.startswith("admin_contest_view_"))
|
||||
dp.callback_query.register(toggle_contest, F.data.startswith("admin_contest_toggle_"))
|
||||
dp.callback_query.register(prompt_edit_summary_times, F.data.startswith("admin_contest_edit_times_"))
|
||||
dp.callback_query.register(delete_contest, F.data.startswith("admin_contest_delete_"))
|
||||
dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_"))
|
||||
dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_"))
|
||||
dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_"))
|
||||
dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create")
|
||||
dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"]))
|
||||
|
||||
dp.message.register(process_title, AdminStates.creating_referral_contest_title)
|
||||
dp.message.register(process_description, AdminStates.creating_referral_contest_description)
|
||||
dp.message.register(process_prize, AdminStates.creating_referral_contest_prize)
|
||||
dp.message.register(process_start_date, AdminStates.creating_referral_contest_start)
|
||||
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
|
||||
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
|
||||
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
|
||||
@@ -0,0 +1,550 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
get_template_by_id,
|
||||
list_templates,
|
||||
update_template_fields,
|
||||
create_round,
|
||||
)
|
||||
from app.database.models import ContestTemplate
|
||||
from app.keyboards.admin import (
|
||||
get_admin_contests_keyboard,
|
||||
get_admin_contests_root_keyboard,
|
||||
get_daily_contest_manage_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.contest_rotation_service import contest_rotation_service
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EDITABLE_FIELDS: Dict[str, Dict] = {
|
||||
"prize_days": {"type": int, "min": 1, "label": "приз (дни)"},
|
||||
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
|
||||
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
|
||||
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
|
||||
"schedule_times": {"type": str, "label": "расписание HH:MM через запятую"},
|
||||
"cooldown_hours": {"type": int, "min": 1, "label": "длительность раунда (часы)"},
|
||||
}
|
||||
|
||||
|
||||
async def _get_template(db: AsyncSession, template_id: int) -> ContestTemplate | None:
|
||||
return await get_template_by_id(db, template_id)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_daily_contests(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
templates = await list_templates(db, enabled_only=False)
|
||||
|
||||
lines = [texts.t("ADMIN_DAILY_CONTESTS_TITLE", "📆 Ежедневные конкурсы")]
|
||||
if not templates:
|
||||
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
|
||||
else:
|
||||
for tpl in templates:
|
||||
status = "🟢" if tpl.is_enabled else "⚪️"
|
||||
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}")
|
||||
|
||||
keyboard_rows = []
|
||||
if templates:
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text="❌ Закрыть все активные раунды", callback_data="admin_daily_close_all")])
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text="� Сбросить попытки во всех активных раундах", callback_data="admin_daily_reset_all_attempts")])
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text="� Запустить все активные конкурсы", callback_data="admin_daily_start_all")])
|
||||
for tpl in templates:
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"⚙️ {tpl.name}",
|
||||
callback_data=f"admin_daily_contest_{tpl.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_daily_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
try:
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
await callback.answer("Некорректный id", show_alert=True)
|
||||
return
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
lines = [
|
||||
f"🏷 <b>{tpl.name}</b> (slug: {tpl.slug})",
|
||||
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
|
||||
f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}",
|
||||
f"Попыток/польз: {tpl.attempts_per_user}",
|
||||
f"Раундов в день: {tpl.times_per_day}",
|
||||
f"Расписание: {tpl.schedule_times or '-'}",
|
||||
f"Длительность раунда: {tpl.cooldown_hours} ч.",
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=get_daily_contest_manage_keyboard(tpl.id, tpl.is_enabled, db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_daily_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
tpl.is_enabled = not tpl.is_enabled
|
||||
await db.commit()
|
||||
await callback.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_round_now(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
if not tpl.is_enabled:
|
||||
tpl.is_enabled = True
|
||||
await db.commit()
|
||||
await db.refresh(tpl)
|
||||
|
||||
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
|
||||
now = datetime.utcnow()
|
||||
ends = now + timedelta(hours=tpl.cooldown_hours)
|
||||
round_obj = await create_round(
|
||||
db,
|
||||
template=tpl,
|
||||
starts_at=now,
|
||||
ends_at=ends,
|
||||
payload=payload,
|
||||
)
|
||||
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
|
||||
tpl,
|
||||
now.replace(tzinfo=None),
|
||||
ends.replace(tzinfo=None),
|
||||
)
|
||||
await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Раунд запущен"), show_alert=True)
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def manual_start_round(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, есть ли уже активный раунд для этого шаблона
|
||||
from app.database.crud.contest import get_active_rounds
|
||||
exists = await get_active_rounds(db, tpl.id)
|
||||
if exists:
|
||||
await callback.answer(texts.t("ADMIN_ROUND_ALREADY_ACTIVE", "Раунд уже активен."), show_alert=True)
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
return
|
||||
|
||||
# Для ручного старта не включаем конкурс, если он выключен
|
||||
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
|
||||
now = datetime.utcnow()
|
||||
ends = now + timedelta(hours=tpl.cooldown_hours)
|
||||
round_obj = await create_round(
|
||||
db,
|
||||
template=tpl,
|
||||
starts_at=now,
|
||||
ends_at=ends,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# Анонсируем всем пользователям (как тест)
|
||||
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
|
||||
tpl,
|
||||
now.replace(tzinfo=None),
|
||||
ends.replace(tzinfo=None),
|
||||
)
|
||||
await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Тестовый раунд запущен"), show_alert=True)
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_edit_field(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
parts = callback.data.split("_")
|
||||
template_id = int(parts[3])
|
||||
field = "_".join(parts[4:]) # поле может содержать подчеркивания
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl or field not in EDITABLE_FIELDS:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
meta = EDITABLE_FIELDS[field]
|
||||
await state.set_state(AdminStates.editing_daily_contest_field)
|
||||
await state.update_data(template_id=template_id, field=field)
|
||||
kb = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_daily_contest_{template_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_FIELD_PROMPT",
|
||||
"Введите новое значение для {label}:",
|
||||
).format(label=meta.get("label", field)),
|
||||
reply_markup=kb,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_field(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
template_id = data.get("template_id")
|
||||
field = data.get("field")
|
||||
if not template_id or not field or field not in EDITABLE_FIELDS:
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
meta = EDITABLE_FIELDS[field]
|
||||
raw = message.text or ""
|
||||
try:
|
||||
if meta["type"] is int:
|
||||
value = int(raw)
|
||||
if meta.get("min") is not None and value < meta["min"]:
|
||||
raise ValueError("min")
|
||||
else:
|
||||
value = raw.strip()
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_INVALID_NUMBER", "Некорректное число"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_template_fields(db, tpl, **{field: value})
|
||||
back_kb = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_daily_contest_{template_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def edit_payload(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(AdminStates.editing_daily_contest_value)
|
||||
await state.update_data(template_id=template_id, field="payload")
|
||||
payload_json = json.dumps(tpl.payload or {}, ensure_ascii=False, indent=2)
|
||||
kb = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_daily_contest_{template_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTEST_PAYLOAD_PROMPT", "Отправьте JSON payload для игры (словарь настроек):\n") + f"<code>{payload_json}</code>",
|
||||
reply_markup=kb,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_payload(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
template_id = data.get("template_id")
|
||||
if not template_id:
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.loads(message.text or "{}")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_INVALID_JSON", "Некорректный JSON"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_template_fields(db, tpl, payload=payload)
|
||||
back_kb = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_daily_contest_{template_id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_all_contests(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
templates = await list_templates(db, enabled_only=True)
|
||||
if not templates:
|
||||
await callback.answer(texts.t("ADMIN_CONTESTS_EMPTY", "Нет активных конкурсов."), show_alert=True)
|
||||
return
|
||||
|
||||
started_count = 0
|
||||
for tpl in templates:
|
||||
from app.database.crud.contest import get_active_round_by_template
|
||||
exists = await get_active_round_by_template(db, tpl.id)
|
||||
if exists:
|
||||
continue # уже запущен
|
||||
|
||||
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
|
||||
now = datetime.utcnow()
|
||||
ends = now + timedelta(hours=tpl.cooldown_hours)
|
||||
round_obj = await create_round(
|
||||
db,
|
||||
template=tpl,
|
||||
starts_at=now,
|
||||
ends_at=ends,
|
||||
payload=payload,
|
||||
)
|
||||
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
|
||||
tpl,
|
||||
now.replace(tzinfo=None),
|
||||
ends.replace(tzinfo=None),
|
||||
)
|
||||
started_count += 1
|
||||
|
||||
message = f"Запущено конкурсов: {started_count}"
|
||||
await callback.answer(message, show_alert=True)
|
||||
await show_daily_contests(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def close_all_rounds(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
from app.database.crud.contest import get_active_rounds
|
||||
active_rounds = await get_active_rounds(db)
|
||||
if not active_rounds:
|
||||
await callback.answer("Нет активных раундов", show_alert=True)
|
||||
return
|
||||
|
||||
for rnd in active_rounds:
|
||||
rnd.status = "finished"
|
||||
await db.commit()
|
||||
|
||||
await callback.answer(f"Закрыто раундов: {len(active_rounds)}", show_alert=True)
|
||||
await show_daily_contests(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def reset_all_attempts(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
from app.database.crud.contest import get_active_rounds
|
||||
active_rounds = await get_active_rounds(db)
|
||||
if not active_rounds:
|
||||
await callback.answer("Нет активных раундов", show_alert=True)
|
||||
return
|
||||
|
||||
total_deleted = 0
|
||||
for rnd in active_rounds:
|
||||
deleted = await clear_attempts(db, rnd.id)
|
||||
total_deleted += deleted
|
||||
|
||||
await callback.answer(f"Попытки сброшены: {total_deleted}", show_alert=True)
|
||||
await show_daily_contests(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def reset_attempts(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.contest import get_active_round_by_template, clear_attempts
|
||||
round_obj = await get_active_round_by_template(db, tpl.id)
|
||||
if not round_obj:
|
||||
await callback.answer("Нет активного раунда", show_alert=True)
|
||||
return
|
||||
|
||||
deleted_count = await clear_attempts(db, round_obj.id)
|
||||
await callback.answer(f"Попытки сброшены: {deleted_count}", show_alert=True)
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def close_round(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.contest import get_active_round_by_template
|
||||
round_obj = await get_active_round_by_template(db, tpl.id)
|
||||
if not round_obj:
|
||||
await callback.answer("Нет активного раунда", show_alert=True)
|
||||
return
|
||||
|
||||
round_obj.status = "finished"
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
|
||||
await callback.answer("Раунд закрыт", show_alert=True)
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_daily_contests, F.data == "admin_contests_daily")
|
||||
dp.callback_query.register(show_daily_contest, F.data.startswith("admin_daily_contest_"))
|
||||
dp.callback_query.register(toggle_daily_contest, F.data.startswith("admin_daily_toggle_"))
|
||||
dp.callback_query.register(start_all_contests, F.data == "admin_daily_start_all")
|
||||
dp.callback_query.register(start_round_now, F.data.startswith("admin_daily_start_"))
|
||||
dp.callback_query.register(manual_start_round, F.data.startswith("admin_daily_manual_"))
|
||||
dp.callback_query.register(close_all_rounds, F.data == "admin_daily_close_all")
|
||||
dp.callback_query.register(reset_all_attempts, F.data == "admin_daily_reset_all_attempts")
|
||||
dp.callback_query.register(reset_attempts, F.data.startswith("admin_daily_reset_attempts_"))
|
||||
dp.callback_query.register(close_round, F.data.startswith("admin_daily_close_"))
|
||||
dp.callback_query.register(prompt_edit_field, F.data.startswith("admin_daily_edit_"))
|
||||
dp.callback_query.register(edit_payload, F.data.startswith("admin_daily_payload_"))
|
||||
|
||||
dp.message.register(process_edit_field, AdminStates.editing_daily_contest_field)
|
||||
dp.message.register(process_payload, AdminStates.editing_daily_contest_value)
|
||||
@@ -449,4 +449,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(
|
||||
admin_commands_help,
|
||||
Command("admin_help")
|
||||
)
|
||||
)
|
||||
|
||||
@@ -74,6 +74,7 @@ async def show_maintenance_panel(
|
||||
{status_emoji} <b>Режим техработ:</b> {status_text}
|
||||
{api_emoji} <b>API Remnawave:</b> {api_text}
|
||||
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
|
||||
🛠️ <b>Автозапуск мониторинга:</b> {'Включен' if status_info['monitoring_configured'] else 'Отключен'}
|
||||
⏱️ <b>Интервал проверки:</b> {status_info['check_interval']}с
|
||||
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
|
||||
{panel_info}
|
||||
@@ -233,7 +234,11 @@ async def check_panel_status(
|
||||
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
|
||||
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
|
||||
]
|
||||
|
||||
|
||||
attempts_used = status_data.get("attempts_used")
|
||||
if attempts_used:
|
||||
message_parts.append(f"🔁 Попыток проверки: {attempts_used}")
|
||||
|
||||
if status_data.get("api_error"):
|
||||
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
|
||||
|
||||
|
||||
+264
-45
@@ -3,8 +3,10 @@ import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import InterfaceError
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
|
||||
from app.config import settings
|
||||
@@ -16,6 +18,7 @@ from app.database.models import (
|
||||
SubscriptionStatus,
|
||||
BroadcastHistory,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
@@ -86,6 +89,56 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> O
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def _persist_broadcast_result(
|
||||
db: AsyncSession,
|
||||
broadcast_history: BroadcastHistory,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
status: str,
|
||||
) -> None:
|
||||
"""Сохраняет результаты рассылки с повторной попыткой при обрыве соединения."""
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = status
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
return
|
||||
except InterfaceError as error:
|
||||
logger.warning(
|
||||
"Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз",
|
||||
exc_info=error,
|
||||
)
|
||||
await db.rollback()
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_session:
|
||||
retry_history = await retry_session.get(BroadcastHistory, broadcast_history.id)
|
||||
if not retry_history:
|
||||
logger.critical(
|
||||
"Не удалось найти запись BroadcastHistory #%s для повторной записи результатов",
|
||||
broadcast_history.id,
|
||||
)
|
||||
return
|
||||
|
||||
retry_history.sent_count = sent_count
|
||||
retry_history.failed_count = failed_count
|
||||
retry_history.status = status
|
||||
retry_history.completed_at = broadcast_history.completed_at
|
||||
await retry_session.commit()
|
||||
logger.info(
|
||||
"Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)",
|
||||
broadcast_history.id,
|
||||
)
|
||||
except Exception as retry_error:
|
||||
logger.critical(
|
||||
"Не удалось сохранить результаты рассылки после восстановления подключения",
|
||||
exc_info=retry_error,
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_menu(
|
||||
@@ -764,54 +817,103 @@ async def confirm_broadcast(
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
|
||||
# чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
|
||||
max_concurrent_sends = 5
|
||||
per_message_delay = 0.05
|
||||
semaphore = asyncio.Semaphore(max_concurrent_sends)
|
||||
|
||||
async def send_single_broadcast(user):
|
||||
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
||||
async with semaphore:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
|
||||
await asyncio.sleep(per_message_delay)
|
||||
return True, user.telegram_id
|
||||
except TelegramRetryAfter as e:
|
||||
retry_delay = min(e.retry_after + 1, 30)
|
||||
logger.warning(
|
||||
f"Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек."
|
||||
)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
await asyncio.sleep(retry_delay)
|
||||
except TelegramForbiddenError:
|
||||
# Пользователь мог удалить бота или запретить сообщения
|
||||
logger.info(f"Рассылка недоступна для пользователя {user.telegram_id}: Forbidden")
|
||||
return False, user.telegram_id
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(
|
||||
f"Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}"
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
return False, user.telegram_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}"
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
|
||||
return False, user.telegram_id
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 50
|
||||
for i in range(0, len(users), batch_size):
|
||||
batch = users[i:i + batch_size]
|
||||
tasks = [send_single_broadcast(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, tuple): # (success, telegram_id)
|
||||
success, _ = result
|
||||
if success:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
elif isinstance(result, Exception):
|
||||
failed_count += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = "completed" if failed_count == 0 else "partial"
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
status = "completed" if failed_count == 0 else "partial"
|
||||
await _persist_broadcast_result(
|
||||
db=db,
|
||||
broadcast_history=broadcast_history,
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
status=status,
|
||||
)
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
@@ -847,7 +949,24 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
|
||||
async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
# Загружаем всех активных пользователей батчами, чтобы не ограничиваться 10к
|
||||
users: list[User] = []
|
||||
offset = 0
|
||||
batch_size = 5000
|
||||
|
||||
while True:
|
||||
batch = await get_users_list(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=batch_size,
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
|
||||
if not batch:
|
||||
break
|
||||
|
||||
users.extend(batch)
|
||||
offset += batch_size
|
||||
|
||||
if target == "all":
|
||||
return users
|
||||
@@ -928,6 +1047,106 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "expiring_subscribers":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 7)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
|
||||
if target == "expired_subscribers":
|
||||
now = datetime.utcnow()
|
||||
expired_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
}
|
||||
expired_users = []
|
||||
for user in users:
|
||||
subscription = user.subscription
|
||||
if subscription:
|
||||
if subscription.status in expired_statuses:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
if subscription.end_date <= now and not subscription.is_active:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
elif user.has_had_paid_subscription:
|
||||
expired_users.append(user)
|
||||
return expired_users
|
||||
|
||||
if target == "canceled_subscribers":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.status == SubscriptionStatus.DISABLED.value
|
||||
]
|
||||
|
||||
if target == "trial_ending":
|
||||
now = datetime.utcnow()
|
||||
in_3_days = now + timedelta(days=3)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and user.subscription.end_date <= in_3_days
|
||||
]
|
||||
|
||||
if target == "trial_expired":
|
||||
now = datetime.utcnow()
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.end_date <= now
|
||||
]
|
||||
|
||||
if target == "autopay_failed":
|
||||
from app.database.models import SubscriptionEvent
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
stmt = select(SubscriptionEvent.user_id).where(
|
||||
and_(
|
||||
SubscriptionEvent.event_type == "autopay_failed",
|
||||
SubscriptionEvent.occurred_at >= week_ago,
|
||||
)
|
||||
).distinct()
|
||||
result = await db.execute(stmt)
|
||||
failed_user_ids = set(result.scalars().all())
|
||||
return [user for user in users if user.id in failed_user_ids]
|
||||
|
||||
if target == "low_balance":
|
||||
threshold_kopeks = 10000 # 100 рублей
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if (user.balance_kopeks or 0) < threshold_kopeks
|
||||
and (user.balance_kopeks or 0) > 0
|
||||
]
|
||||
|
||||
if target == "inactive_30d":
|
||||
threshold = datetime.utcnow() - timedelta(days=30)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.last_activity and user.last_activity < threshold
|
||||
]
|
||||
|
||||
if target == "inactive_60d":
|
||||
threshold = datetime.utcnow() - timedelta(days=60)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.last_activity and user.last_activity < threshold
|
||||
]
|
||||
|
||||
if target == "inactive_90d":
|
||||
threshold = datetime.utcnow() - timedelta(days=90)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.last_activity and user.last_activity < threshold
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
|
||||
@@ -1027,6 +1027,20 @@ async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
# Добавляем дополнительные проверки диапазона значений
|
||||
if (key == "expired_second_wave" and field == "percent") or (key == "expired_third_wave" and field == "percent"):
|
||||
if value < 0 or value > 100:
|
||||
await message.answer("❌ Процент скидки должен быть от 0 до 100.")
|
||||
return
|
||||
elif (key == "expired_second_wave" and field == "hours") or (key == "expired_third_wave" and field == "hours"):
|
||||
if value < 1 or value > 168: # Максимум 168 часов (7 дней)
|
||||
await message.answer("❌ Количество часов должно быть от 1 до 168.")
|
||||
return
|
||||
elif key == "expired_third_wave" and field == "trigger":
|
||||
if value < 2: # Минимум 2 дня
|
||||
await message.answer("❌ Количество дней должно быть не менее 2.")
|
||||
return
|
||||
|
||||
success = False
|
||||
if key == "expired_second_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_second_wave_discount_percent(value)
|
||||
@@ -1075,4 +1089,4 @@ async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
dp.include_router(router)
|
||||
dp.include_router(router)
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
PendingPayment,
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
get_payment_record,
|
||||
list_recent_pending_payments,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago, format_username
|
||||
|
||||
|
||||
PAGE_SIZE = 6
|
||||
|
||||
|
||||
def _method_display(method: PaymentMethod) -> str:
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.get_mulenpay_display_name()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return "PayPalych"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return "Platega"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return "Telegram Stars"
|
||||
return method.value
|
||||
|
||||
|
||||
def _status_info(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
texts,
|
||||
) -> tuple[str, str]:
|
||||
status = (record.status or "").lower()
|
||||
|
||||
if record.is_paid:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
mapping = {
|
||||
"new": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"process": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"fail": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
mapping = {
|
||||
"created": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")),
|
||||
"success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
mapping = {
|
||||
"opened": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"closed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"declined": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"inprogress": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"confirmed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"failed": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancelled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if status in {"pending", "created", "waiting", "check", "processing"}:
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
if status in {"paid", "paid_over"}:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
if status in {"cancel", "canceled", "fail", "failed", "expired"}:
|
||||
return "❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")
|
||||
return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"waiting_for_capture": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"succeeded": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
mapping = {
|
||||
"active": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.TELEGRAM_STARS:
|
||||
if record.is_paid:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
|
||||
return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
|
||||
|
||||
|
||||
def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
|
||||
return False
|
||||
if not record.is_recent():
|
||||
return False
|
||||
status = (record.status or "").lower()
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
return status in {"new", "process"}
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
return status in {"created", "processing", "hold"}
|
||||
if record.method == PaymentMethod.WATA:
|
||||
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
return status in {"pending", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status in {"pending", "waiting_for_capture"}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status in {"active"}
|
||||
return False
|
||||
|
||||
|
||||
def _record_display_number(record: PendingPayment) -> str:
|
||||
if record.identifier:
|
||||
return str(record.identifier)
|
||||
return str(record.local_id)
|
||||
|
||||
|
||||
def _build_list_keyboard(
|
||||
records: list[PendingPayment],
|
||||
*,
|
||||
page: int,
|
||||
total_pages: int,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
texts = get_texts(language)
|
||||
|
||||
for record in records:
|
||||
number = _record_display_number(record)
|
||||
details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}")
|
||||
try:
|
||||
button_text = details_template.format(number=number)
|
||||
except Exception: # pragma: no cover - fallback for broken localization
|
||||
button_text = f"📄 {number}"
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_payment_{record.method.value}_{record.local_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_row: list[InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_payments_page_{page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}",
|
||||
callback_data="admin_payments_page_current",
|
||||
)
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_payments_page_{page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
buttons.append(navigation_row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def _build_detail_keyboard(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
payment = record.payment
|
||||
payment_url = getattr(payment, "payment_url", None)
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
payment_url = payment.link_url or payment.link_page_url or payment_url
|
||||
elif record.method == PaymentMethod.WATA:
|
||||
payment_url = payment.url or payment_url
|
||||
elif record.method == PaymentMethod.YOOKASSA:
|
||||
payment_url = getattr(payment, "confirmation_url", None) or payment_url
|
||||
elif record.method == PaymentMethod.CRYPTOBOT:
|
||||
payment_url = (
|
||||
payment.bot_invoice_url
|
||||
or payment.mini_app_invoice_url
|
||||
or payment.web_app_invoice_url
|
||||
or payment_url
|
||||
)
|
||||
|
||||
if payment_url:
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"),
|
||||
url=payment_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if _is_checkable(record):
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"),
|
||||
callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def _format_user_line(user: User) -> str:
|
||||
username = format_username(user.username, user.telegram_id, user.full_name)
|
||||
return f"👤 {html.escape(username)} (<code>{user.telegram_id}</code>)"
|
||||
|
||||
|
||||
def _build_record_lines(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
index: int,
|
||||
texts,
|
||||
language: str,
|
||||
) -> list[str]:
|
||||
amount = settings.format_price(record.amount_kopeks)
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
crypto_amount = getattr(record.payment, "amount", None)
|
||||
crypto_asset = getattr(record.payment, "asset", None)
|
||||
if crypto_amount and crypto_asset:
|
||||
amount = f"{crypto_amount} {crypto_asset}"
|
||||
method_name = _method_display(record.method)
|
||||
emoji, status_text = _status_info(record, texts=texts)
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
identifier = (
|
||||
html.escape(str(record.identifier)) if record.identifier else ""
|
||||
)
|
||||
display_number = html.escape(_record_display_number(record))
|
||||
|
||||
lines = [
|
||||
f"{index}. <b>{html.escape(method_name)}</b> — {amount}",
|
||||
f" {emoji} {status_text}",
|
||||
f" 🕒 {created} ({age})",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if identifier:
|
||||
lines.append(f" 🆔 ID: <code>{identifier}</code>")
|
||||
else:
|
||||
lines.append(f" 🆔 ID: <code>{display_number}</code>")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str:
|
||||
method_name = _method_display(record.method)
|
||||
emoji, status_text = _status_info(record, texts=texts)
|
||||
amount = settings.format_price(record.amount_kopeks)
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
crypto_amount = getattr(record.payment, "amount", None)
|
||||
crypto_asset = getattr(record.payment, "asset", None)
|
||||
if crypto_amount and crypto_asset:
|
||||
amount = f"{crypto_amount} {crypto_asset}"
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
raw_identifier = record.identifier if record.identifier else record.local_id
|
||||
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else "—"
|
||||
lines = [
|
||||
texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 <b>Payment details</b>"),
|
||||
"",
|
||||
f"<b>{html.escape(method_name)}</b>",
|
||||
f"{emoji} {status_text}",
|
||||
"",
|
||||
f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
|
||||
f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
|
||||
f"🆔 ID: <code>{identifier}</code>",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if record.expires_at:
|
||||
expires_at = format_datetime(record.expires_at)
|
||||
lines.append(f"⏳ {texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}")
|
||||
|
||||
payment = record.payment
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
if getattr(payment, "payment_status", None):
|
||||
lines.append(
|
||||
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
|
||||
f"{html.escape(str(payment.payment_status))}"
|
||||
)
|
||||
if getattr(payment, "payment_method", None):
|
||||
lines.append(
|
||||
f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
|
||||
f"{html.escape(str(payment.payment_method))}"
|
||||
)
|
||||
if getattr(payment, "balance_amount", None):
|
||||
lines.append(
|
||||
f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: "
|
||||
f"{html.escape(str(payment.balance_amount))}"
|
||||
)
|
||||
if getattr(payment, "payer_account", None):
|
||||
lines.append(
|
||||
f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: "
|
||||
f"{html.escape(str(payment.payer_account))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
if getattr(payment, "mulen_payment_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.mulen_payment_id))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
if getattr(payment, "order_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.order_id))}"
|
||||
)
|
||||
if getattr(payment, "terminal_public_id", None):
|
||||
lines.append(
|
||||
f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if getattr(payment, "order_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.order_id))}"
|
||||
)
|
||||
if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None):
|
||||
lines.append(
|
||||
f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: "
|
||||
f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
if getattr(payment, "payment_method_type", None):
|
||||
lines.append(
|
||||
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
|
||||
f"{html.escape(str(payment.payment_method_type))}"
|
||||
)
|
||||
if getattr(payment, "confirmation_url", None):
|
||||
lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above."))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
if getattr(payment, "amount", None) and getattr(payment, "asset", None):
|
||||
lines.append(
|
||||
f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: "
|
||||
f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}"
|
||||
)
|
||||
if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None):
|
||||
lines.append(
|
||||
texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")
|
||||
)
|
||||
if getattr(payment, "status", None):
|
||||
lines.append(
|
||||
f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
|
||||
f"{html.escape(str(payment.status))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.TELEGRAM_STARS:
|
||||
description = getattr(payment, "description", "") or ""
|
||||
if description:
|
||||
lines.append(f"📝 {html.escape(description)}")
|
||||
if getattr(payment, "external_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.external_id))}"
|
||||
)
|
||||
|
||||
if _is_checkable(record):
|
||||
lines.append("")
|
||||
lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "ℹ️ You can trigger a manual status check."))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]:
|
||||
suffix = payload[len(prefix) :]
|
||||
try:
|
||||
method_str, identifier = suffix.rsplit("_", 1)
|
||||
method = PaymentMethod(method_str)
|
||||
payment_id = int(identifier)
|
||||
return method, payment_id
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_payments_overview(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
page = 1
|
||||
if callback.data.startswith("admin_payments_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
records = await list_recent_pending_payments(db)
|
||||
total = len(records)
|
||||
total_pages = max(1, math.ceil(total / PAGE_SIZE))
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
start_index = (page - 1) * PAGE_SIZE
|
||||
page_records = records[start_index : start_index + PAGE_SIZE]
|
||||
|
||||
header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 <b>Top-up verification</b>")
|
||||
description = texts.t(
|
||||
"ADMIN_PAYMENTS_DESCRIPTION",
|
||||
"Pending invoices created during the last 24 hours.",
|
||||
)
|
||||
notice = texts.t(
|
||||
"ADMIN_PAYMENTS_NOTICE",
|
||||
"Only invoices younger than 24 hours and waiting for payment can be checked.",
|
||||
)
|
||||
|
||||
lines = [header, "", description]
|
||||
|
||||
if page_records:
|
||||
for idx, record in enumerate(page_records, start=start_index + 1):
|
||||
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
|
||||
lines.append("")
|
||||
lines.append(notice)
|
||||
else:
|
||||
empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.")
|
||||
lines.append("")
|
||||
lines.append(empty_text)
|
||||
|
||||
keyboard = _build_list_keyboard(
|
||||
page_records,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(line for line in lines if line is not None),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_payment_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
record: PendingPayment,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
text = _build_payment_details_text(record, texts=texts, language=db_user.language)
|
||||
keyboard = _build_detail_keyboard(record, language=db_user.language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_payment_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_")
|
||||
if not parsed:
|
||||
await callback.answer("❌ Invalid payment reference", show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
if not record:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await _render_payment_details(callback, db_user, record)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def manual_check_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_")
|
||||
if not parsed:
|
||||
await callback.answer("❌ Invalid payment reference", show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not record:
|
||||
await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True)
|
||||
return
|
||||
|
||||
if not _is_checkable(record):
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated = await run_manual_check(db, method, payment_id, payment_service)
|
||||
|
||||
if not updated:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await _render_payment_details(callback, db_user, updated)
|
||||
|
||||
if updated.status != record.status or updated.is_paid != record.is_paid:
|
||||
emoji, status_text = _status_info(updated, texts=texts)
|
||||
message = texts.t(
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS",
|
||||
"Status updated: {status}",
|
||||
).format(status=f"{emoji} {status_text}")
|
||||
else:
|
||||
message = texts.t(
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES",
|
||||
"Status is unchanged after the check.",
|
||||
)
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_"))
|
||||
dp.callback_query.register(
|
||||
show_payment_details,
|
||||
F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"),
|
||||
)
|
||||
dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_"))
|
||||
dp.callback_query.register(show_payments_overview, F.data == "admin_payments")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,24 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
label_en="📱 Device limit",
|
||||
action="input",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_PAYMENT_ENABLED",
|
||||
section="trial",
|
||||
label_ru="💳 Платная активация",
|
||||
label_en="💳 Paid activation",
|
||||
action="toggle",
|
||||
description_ru="Если включено — за активацию триала будет списываться указанная сумма.",
|
||||
description_en="When enabled, the configured amount is charged during trial activation.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_ACTIVATION_PRICE",
|
||||
section="trial",
|
||||
label_ru="💰 Стоимость активации",
|
||||
label_en="💰 Activation price",
|
||||
action="price",
|
||||
description_ru="Указывается в копейках. 0 — бесплатная активация.",
|
||||
description_en="Amount in kopeks. 0 — free activation.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_ADD_REMAINING_DAYS_TO_PAID",
|
||||
section="trial",
|
||||
@@ -105,15 +123,6 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.",
|
||||
description_en="When enabled, remaining trial days are added to paid subscription duration.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_SQUAD_UUID",
|
||||
section="trial",
|
||||
label_ru="🆔 Squad UUID",
|
||||
label_en="🆔 Squad UUID",
|
||||
action="input",
|
||||
description_ru="Можно оставить пустым, если не требуется назначать пробные подписки в конкретный Squad.",
|
||||
description_en="Leave empty if trial subscriptions shouldn't be bound to a specific Squad.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -125,6 +134,24 @@ CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
label_en="💳 Base subscription price",
|
||||
action="price",
|
||||
),
|
||||
SettingEntry(
|
||||
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED",
|
||||
section="core",
|
||||
label_ru="🎟️ Базовые скидки для групп",
|
||||
label_en="🎟️ Base group discounts",
|
||||
action="toggle",
|
||||
description_ru="Включает применение базовых скидок для групповых промо-периодов.",
|
||||
description_en="Enables base discounts for promo group periods.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS",
|
||||
section="core",
|
||||
label_ru="🔖 Скидки по периодам",
|
||||
label_en="🔖 Period discounts",
|
||||
action="input",
|
||||
description_ru="Формат: список пар дней и скидки через запятую (например 30:10,60:20).",
|
||||
description_en="Format: comma-separated day/discount pairs (e.g. 30:10,60:20).",
|
||||
),
|
||||
SettingEntry(
|
||||
key="DEFAULT_DEVICE_LIMIT",
|
||||
section="core",
|
||||
@@ -196,6 +223,26 @@ SETTING_ENTRY_BY_KEY: Dict[str, SettingEntry] = {
|
||||
entry.key: entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
|
||||
}
|
||||
|
||||
SETTING_ENTRIES: Tuple[SettingEntry, ...] = tuple(
|
||||
entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
|
||||
)
|
||||
|
||||
SETTING_KEY_TO_TOKEN: Dict[str, str] = {
|
||||
entry.key: f"s{index}" for index, entry in enumerate(SETTING_ENTRIES)
|
||||
}
|
||||
|
||||
SETTING_TOKEN_TO_KEY: Dict[str, str] = {
|
||||
token: key for key, token in SETTING_KEY_TO_TOKEN.items()
|
||||
}
|
||||
|
||||
|
||||
def _encode_setting_callback_key(key: str) -> str:
|
||||
return SETTING_KEY_TO_TOKEN.get(key, key)
|
||||
|
||||
|
||||
def _decode_setting_callback_key(raw: str) -> str:
|
||||
return SETTING_TOKEN_TO_KEY.get(raw, raw)
|
||||
|
||||
|
||||
def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]:
|
||||
order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"])
|
||||
@@ -290,11 +337,14 @@ def _format_trial_summary(lang_code: str) -> str:
|
||||
duration = settings.TRIAL_DURATION_DAYS
|
||||
traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
devices = settings.TRIAL_DEVICE_LIMIT
|
||||
price_note = ""
|
||||
if settings.is_trial_paid_activation_enabled():
|
||||
price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}"
|
||||
|
||||
traffic_label = _format_traffic_label(traffic, lang_code, short=True)
|
||||
devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱"
|
||||
days_suffix = "д" if lang_code == "ru" else "d"
|
||||
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}"
|
||||
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}"
|
||||
|
||||
|
||||
def _format_core_summary(lang_code: str) -> str:
|
||||
@@ -435,7 +485,9 @@ def _build_settings_section(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_pricing_toggle:{section}:{entry.key}",
|
||||
callback_data=(
|
||||
f"admin_pricing_toggle:{section}:{_encode_setting_callback_key(entry.key)}"
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -449,7 +501,7 @@ def _build_settings_section(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{icon} {option.label(lang_code)}",
|
||||
callback_data=(
|
||||
f"admin_pricing_choice:{section}:{entry.key}:{option.value}"
|
||||
f"admin_pricing_choice:{section}:{_encode_setting_callback_key(entry.key)}:{option.value}"
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -465,7 +517,9 @@ def _build_settings_section(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_pricing_setting:{section}:{entry.key}",
|
||||
callback_data=(
|
||||
f"admin_pricing_setting:{section}:{_encode_setting_callback_key(entry.key)}"
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -927,11 +981,12 @@ async def start_setting_edit(
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
try:
|
||||
_, section, key = callback.data.split(":", 2)
|
||||
_, section, raw_key = callback.data.split(":", 2)
|
||||
except ValueError:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
key = _decode_setting_callback_key(raw_key)
|
||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||
texts = get_texts(db_user.language)
|
||||
lang_code = _language_code(db_user.language)
|
||||
@@ -1128,11 +1183,12 @@ async def toggle_setting(
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
try:
|
||||
_, section, key = callback.data.split(":", 2)
|
||||
_, section, raw_key = callback.data.split(":", 2)
|
||||
except ValueError:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
key = _decode_setting_callback_key(raw_key)
|
||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||
if not entry or entry.action != "toggle":
|
||||
await callback.answer()
|
||||
@@ -1159,11 +1215,12 @@ async def select_setting_choice(
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
try:
|
||||
_, section, key, value_raw = callback.data.split(":", 3)
|
||||
_, section, raw_key, value_raw = callback.data.split(":", 3)
|
||||
except ValueError:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
key = _decode_setting_callback_key(raw_key)
|
||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||
if not entry or entry.action != "choice" or not entry.choices:
|
||||
await callback.answer()
|
||||
|
||||
@@ -211,6 +211,14 @@ def _format_rubles(amount_kopeks: int) -> str:
|
||||
return formatted.replace(",", " ")
|
||||
|
||||
|
||||
def _format_priority_line(texts, group: PromoGroup) -> str:
|
||||
priority = getattr(group, "priority", 0)
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUP_PRIORITY_LINE",
|
||||
"🎯 Приоритет: {priority}",
|
||||
).format(priority=priority)
|
||||
|
||||
|
||||
def _format_auto_assign_line(texts, group: PromoGroup) -> str:
|
||||
threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0
|
||||
|
||||
@@ -294,6 +302,7 @@ def _build_edit_menu_content(
|
||||
lines = [header]
|
||||
lines.extend(_format_discount_lines(texts, group))
|
||||
lines.append(_format_addon_discounts_line(texts, group))
|
||||
lines.append(_format_priority_line(texts, group))
|
||||
lines.append(_format_auto_assign_line(texts, group))
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, language)
|
||||
@@ -318,6 +327,15 @@ def _build_edit_menu_content(
|
||||
callback_data=f"promo_group_edit_field_{group.id}_name",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY",
|
||||
"🎯 Приоритет",
|
||||
),
|
||||
callback_data=f"promo_group_edit_field_{group.id}_priority",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
@@ -640,6 +658,32 @@ async def process_create_group_name(message: types.Message, state: FSMContext):
|
||||
return
|
||||
|
||||
await state.update_data(new_group_name=name)
|
||||
await state.set_state(AdminStates.creating_promo_group_priority)
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT",
|
||||
"Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def process_create_group_priority(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
priority = int(message.text)
|
||||
if priority < 0:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
|
||||
"❌ Приоритет должен быть неотрицательным целым числом",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(new_group_priority=priority)
|
||||
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
@@ -772,6 +816,7 @@ async def process_create_group_auto_assign(
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
data["new_group_name"],
|
||||
priority=data.get("new_group_priority", 0),
|
||||
traffic_discount_percent=data["new_group_traffic"],
|
||||
server_discount_percent=data["new_group_servers"],
|
||||
device_discount_percent=data["new_group_devices"],
|
||||
@@ -862,6 +907,12 @@ async def prompt_edit_promo_group_field(
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
|
||||
"Введите новое название промогруппы (текущее: {name}):",
|
||||
).format(name=group.name)
|
||||
elif field == "priority":
|
||||
await state.set_state(AdminStates.editing_promo_group_priority)
|
||||
prompt = texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT",
|
||||
"Введите новый приоритет (текущий: {current}):",
|
||||
).format(current=getattr(group, "priority", 0))
|
||||
elif field == "traffic":
|
||||
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
|
||||
prompt = texts.t(
|
||||
@@ -935,6 +986,48 @@ async def process_edit_group_name(
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_priority(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
priority = int(message.text)
|
||||
if priority < 0:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
|
||||
"❌ Приоритет должен быть неотрицательным целым числом",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
group = await update_promo_group(db, group, priority=priority)
|
||||
await state.set_state(AdminStates.editing_promo_group_menu)
|
||||
|
||||
await _send_edit_menu_after_update(
|
||||
message,
|
||||
texts,
|
||||
group,
|
||||
data.get("language", db_user.language),
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_traffic(
|
||||
@@ -1158,8 +1251,9 @@ async def show_promo_group_members(
|
||||
lines = []
|
||||
for index, user in enumerate(members, start=offset + 1):
|
||||
username = f"@{user.username}" if user.username else "—"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
lines.append(
|
||||
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
f"{index}. {user_link} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
)
|
||||
body = "\n".join(lines)
|
||||
|
||||
@@ -1181,6 +1275,7 @@ async def show_promo_group_members(
|
||||
await callback.message.edit_text(
|
||||
f"{title}\n\n{body}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -1323,6 +1418,10 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
|
||||
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
|
||||
dp.message.register(
|
||||
process_create_group_priority,
|
||||
AdminStates.creating_promo_group_priority,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_traffic,
|
||||
AdminStates.creating_promo_group_traffic_discount,
|
||||
@@ -1345,6 +1444,10 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
|
||||
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
|
||||
dp.message.register(
|
||||
process_edit_group_priority,
|
||||
AdminStates.editing_promo_group_priority,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_traffic,
|
||||
AdminStates.editing_promo_group_traffic_discount,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
@@ -1896,66 +1897,94 @@ async def _send_offer_to_users(
|
||||
squad_name: Optional[str],
|
||||
effect_type: str,
|
||||
) -> Tuple[int, int]:
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
sent = 0
|
||||
failed = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
offer_record = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=user.subscription.id if user.subscription else None,
|
||||
notification_type=f"promo_template_{template.id}",
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=template.valid_hours,
|
||||
effect_type=effect_type,
|
||||
extra_data={
|
||||
"template_id": template.id,
|
||||
"offer_type": template.offer_type,
|
||||
"test_duration_hours": template.test_duration_hours,
|
||||
"test_squad_uuids": template.test_squad_uuids,
|
||||
"active_discount_hours": template.active_discount_hours,
|
||||
},
|
||||
)
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
user_texts = get_texts(user.language or db_user.language)
|
||||
keyboard_rows: List[List[InlineKeyboardButton]] = [
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=template.button_text,
|
||||
callback_data=f"claim_discount_{offer_record.id}",
|
||||
async def send_single_offer(user):
|
||||
"""Отправляет одно предложение с семафором ограничения"""
|
||||
async with semaphore:
|
||||
try:
|
||||
# Используем отдельную сессию для изоляции транзакции
|
||||
async with AsyncSessionLocal() as new_db:
|
||||
offer_record = await upsert_discount_offer(
|
||||
new_db,
|
||||
user_id=user.id,
|
||||
subscription_id=getattr(user, "subscription", None).id if getattr(user, "subscription", None) else None,
|
||||
notification_type=f"promo_template_{template.id}",
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=template.valid_hours,
|
||||
effect_type=effect_type,
|
||||
extra_data={
|
||||
"template_id": template.id,
|
||||
"offer_type": template.offer_type,
|
||||
"test_duration_hours": template.test_duration_hours,
|
||||
"test_squad_uuids": template.test_squad_uuids,
|
||||
"active_discount_hours": template.active_discount_hours,
|
||||
},
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
|
||||
callback_data="promo_offer_close",
|
||||
)
|
||||
])
|
||||
user_texts = get_texts(user.language or db_user.language)
|
||||
keyboard_rows: List[List[InlineKeyboardButton]] = [
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=template.button_text,
|
||||
callback_data=f"claim_discount_{offer_record.id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
|
||||
callback_data="promo_offer_close",
|
||||
)
|
||||
])
|
||||
|
||||
message_text = _render_template_text(
|
||||
template,
|
||||
user.language or db_user.language,
|
||||
server_name=squad_name,
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
sent += 1
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
|
||||
failed += 1
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
|
||||
failed += 1
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
message_text = _render_template_text(
|
||||
template,
|
||||
user.language or db_user.language,
|
||||
server_name=squad_name,
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return True
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
|
||||
return False
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
|
||||
return False
|
||||
|
||||
# Отправляем предложения пакетами для эффективности
|
||||
batch_size = 100
|
||||
for i in range(0, len(users), batch_size):
|
||||
batch = users[i:i + batch_size]
|
||||
tasks = [send_single_offer(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, bool): # Успешно или неуспешно
|
||||
if result:
|
||||
sent += 1
|
||||
else:
|
||||
failed += 1
|
||||
elif isinstance(result, Exception): # Ошибка выполнения задачи
|
||||
failed += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return sent, failed
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ from app.localization.texts import get_texts
|
||||
from app.database.crud.promocode import (
|
||||
get_promocodes_list, get_promocodes_count, create_promocode,
|
||||
get_promocode_statistics, get_promocode_by_code, update_promocode,
|
||||
delete_promocode
|
||||
delete_promocode, get_promocode_by_id
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime
|
||||
|
||||
@@ -81,16 +82,24 @@ async def show_promocodes_list(
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
type_emoji = {
|
||||
"balance": "💰",
|
||||
"subscription_days": "📅",
|
||||
"trial_subscription": "🎁",
|
||||
"promo_group": "🏷️"
|
||||
}.get(promo.type, "🎫")
|
||||
|
||||
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
|
||||
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
|
||||
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 Дней: {promo.subscription_days}\n"
|
||||
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f"🏷️ Промогруппа: {promo.promo_group.name}\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
@@ -129,15 +138,20 @@ async def show_promocode_management(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
type_emoji = {
|
||||
"balance": "💰",
|
||||
"subscription_days": "📅",
|
||||
"trial_subscription": "🎁",
|
||||
"promo_group": "🏷️"
|
||||
}.get(promo.type, "🎫")
|
||||
|
||||
text = f"""
|
||||
🎫 <b>Управление промокодом</b>
|
||||
|
||||
@@ -145,12 +159,17 @@ async def show_promocode_management(
|
||||
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
|
||||
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
|
||||
"""
|
||||
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
|
||||
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f"🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n"
|
||||
elif promo.promo_group_id:
|
||||
text += f"🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
@@ -200,8 +219,8 @@ async def show_promocode_edit_menu(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -445,13 +464,14 @@ async def select_promocode_type(
|
||||
state: FSMContext
|
||||
):
|
||||
promo_type = callback.data.split('_')[-1]
|
||||
|
||||
|
||||
type_names = {
|
||||
"balance": "💰 Пополнение баланса",
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка"
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка",
|
||||
"group": "🏷️ Промогруппа"
|
||||
}
|
||||
|
||||
|
||||
await state.update_data(promocode_type=promo_type)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -509,6 +529,77 @@ async def process_promocode_code(
|
||||
f"Введите количество дней тестовой подписки:"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
elif promo_type == "group":
|
||||
# Show promo group selection
|
||||
groups_with_counts = await get_promo_groups_with_counts(db, limit=50)
|
||||
|
||||
if not groups_with_counts:
|
||||
await message.answer(
|
||||
"❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = []
|
||||
text = f"🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n"
|
||||
|
||||
for promo_group, user_count in groups_with_counts:
|
||||
text += f"• {promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n"
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{promo_group.name} (↑{promo_group.priority})",
|
||||
callback_data=f"promo_select_group_{promo_group.id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await state.set_state(AdminStates.selecting_promo_group)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promo_group_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Handle promo group selection for promocode"""
|
||||
try:
|
||||
promo_group_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промогруппы", show_alert=True)
|
||||
return
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, promo_group_id)
|
||||
if not promo_group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
promo_group_id=promo_group_id,
|
||||
promo_group_name=promo_group.name
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🏷️ <b>Промокод для промогруппы</b>\n\n"
|
||||
f"Промогруппа: {promo_group.name}\n"
|
||||
f"Приоритет: {promo_group.priority}\n\n"
|
||||
f"📊 Введите количество использований промокода (или 0 для безлимита):"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.setting_promocode_uses)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -557,8 +648,8 @@ async def handle_edit_value(
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
edit_action = data.get('edit_action')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -643,8 +734,8 @@ async def handle_edit_uses(
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -708,17 +799,20 @@ async def process_promocode_expiry(
|
||||
promo_type = data.get('promocode_type')
|
||||
value = data.get('promocode_value', 0)
|
||||
max_uses = data.get('promocode_max_uses', 1)
|
||||
|
||||
promo_group_id = data.get('promo_group_id')
|
||||
promo_group_name = data.get('promo_group_name')
|
||||
|
||||
valid_until = None
|
||||
if expiry_days > 0:
|
||||
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
|
||||
type_map = {
|
||||
"balance": PromoCodeType.BALANCE,
|
||||
"days": PromoCodeType.SUBSCRIPTION_DAYS,
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION,
|
||||
"group": PromoCodeType.PROMO_GROUP
|
||||
}
|
||||
|
||||
|
||||
promocode = await create_promocode(
|
||||
db=db,
|
||||
code=code,
|
||||
@@ -727,27 +821,31 @@ async def process_promocode_expiry(
|
||||
subscription_days=value if promo_type in ["days", "trial"] else 0,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=db_user.id
|
||||
created_by=db_user.id,
|
||||
promo_group_id=promo_group_id if promo_type == "group" else None
|
||||
)
|
||||
|
||||
type_names = {
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка"
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка",
|
||||
"group": "Промогруппа"
|
||||
}
|
||||
|
||||
|
||||
summary_text = f"""
|
||||
✅ <b>Промокод создан!</b>
|
||||
|
||||
🎫 <b>Код:</b> <code>{promocode.code}</code>
|
||||
📝 <b>Тип:</b> {type_names.get(promo_type)}
|
||||
"""
|
||||
|
||||
|
||||
if promo_type == "balance":
|
||||
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
|
||||
elif promo_type in ["days", "trial"]:
|
||||
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
|
||||
|
||||
elif promo_type == "group" and promo_group_name:
|
||||
summary_text += f"🏷️ <b>Промогруппа:</b> {promo_group_name}\n"
|
||||
|
||||
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
|
||||
|
||||
if promocode.valid_until:
|
||||
@@ -775,8 +873,8 @@ async def handle_edit_expiry(
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -822,8 +920,8 @@ async def toggle_promocode_status(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -849,8 +947,8 @@ async def confirm_delete_promocode(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -897,8 +995,8 @@ async def delete_promocode_confirmed(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -921,8 +1019,8 @@ async def show_promocode_stats(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -1007,6 +1105,7 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
|
||||
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
|
||||
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
|
||||
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
|
||||
|
||||
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
|
||||
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
|
||||
|
||||
@@ -68,7 +68,6 @@ 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.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
|
||||
@@ -105,7 +104,6 @@ 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.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<i>🕐 Время: {current_time}</i>
|
||||
@@ -190,7 +188,6 @@ 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.get_referred_user_reward_kopeks())}
|
||||
|
||||
<b>Комиссионные:</b>
|
||||
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
+168
-57
@@ -45,7 +45,7 @@ MIGRATION_PAGE_SIZE = 8
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
if seconds < 1:
|
||||
return "<1с"
|
||||
return "менее 1с"
|
||||
|
||||
minutes, sec = divmod(int(seconds), 60)
|
||||
if minutes:
|
||||
@@ -1316,7 +1316,29 @@ async def show_node_details(
|
||||
|
||||
status_emoji = "🟢" if node["is_node_online"] else "🔴"
|
||||
xray_emoji = "✅" if node["is_xray_running"] else "❌"
|
||||
|
||||
|
||||
status_change = (
|
||||
format_datetime(node["last_status_change"])
|
||||
if node.get("last_status_change")
|
||||
else "—"
|
||||
)
|
||||
created_at = (
|
||||
format_datetime(node["created_at"])
|
||||
if node.get("created_at")
|
||||
else "—"
|
||||
)
|
||||
updated_at = (
|
||||
format_datetime(node["updated_at"])
|
||||
if node.get("updated_at")
|
||||
else "—"
|
||||
)
|
||||
notify_percent = (
|
||||
f"{node['notify_percent']}%" if node.get("notify_percent") is not None else "—"
|
||||
)
|
||||
cpu_info = node.get("cpu_model") or "—"
|
||||
if node.get("cpu_count"):
|
||||
cpu_info = f"{node['cpu_count']}x {cpu_info}"
|
||||
|
||||
text = f"""
|
||||
🖥️ <b>Нода: {node['name']}</b>
|
||||
|
||||
@@ -1325,15 +1347,29 @@ async def show_node_details(
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Подключена: {'📡 Да' if node['is_connected'] else '📵 Нет'}
|
||||
- Отключена: {'❌ Да' if node['is_disabled'] else '✅ Нет'}
|
||||
- Изменение статуса: {status_change}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
|
||||
<b>Информация:</b>
|
||||
- Адрес: {node['address']}
|
||||
- Страна: {node['country_code']}
|
||||
- Пользователей онлайн: {node['users_online']}
|
||||
- CPU: {cpu_info}
|
||||
- RAM: {node.get('total_ram') or '—'}
|
||||
- Провайдер: {node.get('provider_uuid') or '—'}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'])}
|
||||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||||
- Уведомления: {notify_percent}
|
||||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||||
|
||||
<b>Метаданные:</b>
|
||||
- Создана: {created_at}
|
||||
- Обновлена: {updated_at}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -1350,28 +1386,18 @@ async def manage_node(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
success = await remnawave_service.manage_node(node_uuid, action)
|
||||
|
||||
if success:
|
||||
action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"}
|
||||
await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}")
|
||||
else:
|
||||
await callback.answer("❌ Ошибка выполнения действия", show_alert=True)
|
||||
|
||||
await show_node_details(
|
||||
types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"admin_node_manage_{node_uuid}",
|
||||
message=callback.message
|
||||
),
|
||||
db_user,
|
||||
db
|
||||
)
|
||||
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
success = await remnawave_service.manage_node(node_uuid, action)
|
||||
|
||||
if success:
|
||||
action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"}
|
||||
await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}")
|
||||
else:
|
||||
await callback.answer("❌ Ошибка выполнения действия", show_alert=True)
|
||||
|
||||
await show_node_details(callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -1407,10 +1433,32 @@ async def show_node_statistics(
|
||||
if stats.get('nodeUuid') == node_uuid:
|
||||
node_realtime = stats
|
||||
break
|
||||
|
||||
|
||||
status_change = (
|
||||
format_datetime(node["last_status_change"])
|
||||
if node.get("last_status_change")
|
||||
else "—"
|
||||
)
|
||||
created_at = (
|
||||
format_datetime(node["created_at"])
|
||||
if node.get("created_at")
|
||||
else "—"
|
||||
)
|
||||
updated_at = (
|
||||
format_datetime(node["updated_at"])
|
||||
if node.get("updated_at")
|
||||
else "—"
|
||||
)
|
||||
notify_percent = (
|
||||
f"{node['notify_percent']}%" if node.get("notify_percent") is not None else "—"
|
||||
)
|
||||
cpu_info = node.get("cpu_model") or "—"
|
||||
if node.get("cpu_count"):
|
||||
cpu_info = f"{node['cpu_count']}x {cpu_info}"
|
||||
|
||||
status_emoji = "🟢" if node["is_node_online"] else "🔴"
|
||||
xray_emoji = "✅" if node["is_xray_running"] else "❌"
|
||||
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика ноды: {node['name']}</b>
|
||||
|
||||
@@ -1418,10 +1466,26 @@ async def show_node_statistics(
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Пользователей онлайн: {node['users_online'] or 0}
|
||||
- Изменение статуса: {status_change}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
|
||||
<b>Ресурсы:</b>
|
||||
- CPU: {cpu_info}
|
||||
- RAM: {node.get('total_ram') or '—'}
|
||||
- Провайдер: {node.get('provider_uuid') or '—'}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
|
||||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||||
- Уведомления: {notify_percent}
|
||||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||||
|
||||
<b>Метаданные:</b>
|
||||
- Создана: {created_at}
|
||||
- Обновлена: {updated_at}
|
||||
"""
|
||||
|
||||
if node_realtime:
|
||||
@@ -1456,18 +1520,25 @@ async def show_node_statistics(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}")
|
||||
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика ноды: {node['name']}</b>
|
||||
|
||||
<b>Статус:</b>
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Пользователей онлайн: {node['users_online'] or 0}
|
||||
- Изменение статуса: {format_datetime(node.get('last_status_change')) if node.get('last_status_change') else '—'}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
|
||||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||||
- Уведомления: {node.get('notify_percent') or '—'}
|
||||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||||
|
||||
⚠️ <b>Детальная статистика временно недоступна</b>
|
||||
Возможные причины:
|
||||
@@ -1566,17 +1637,11 @@ async def manage_squad_action(
|
||||
await callback.answer("❌ Ошибка удаления сквада", show_alert=True)
|
||||
return
|
||||
|
||||
await show_squad_details(
|
||||
types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"admin_squad_manage_{squad_uuid}",
|
||||
message=callback.message
|
||||
),
|
||||
db_user,
|
||||
db
|
||||
)
|
||||
refreshed_callback = callback.model_copy(
|
||||
update={"data": f"admin_squad_manage_{squad_uuid}"}
|
||||
).as_(callback.bot)
|
||||
|
||||
await show_squad_details(refreshed_callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -1734,15 +1799,11 @@ async def cancel_squad_rename(
|
||||
|
||||
await state.clear()
|
||||
|
||||
new_callback = types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"squad_edit_{squad_uuid}",
|
||||
message=callback.message
|
||||
)
|
||||
|
||||
await show_squad_edit_menu(new_callback, db_user, db)
|
||||
refreshed_callback = callback.model_copy(
|
||||
update={"data": f"squad_edit_{squad_uuid}"}
|
||||
).as_(callback.bot)
|
||||
|
||||
await show_squad_edit_menu(refreshed_callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -1953,15 +2014,11 @@ async def show_squad_edit_menu_short(
|
||||
await callback.answer("❌ Сквад не найден", show_alert=True)
|
||||
return
|
||||
|
||||
new_callback = types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data=f"squad_edit_{full_squad_uuid}",
|
||||
message=callback.message
|
||||
)
|
||||
|
||||
await show_squad_edit_menu(new_callback, db_user, db)
|
||||
refreshed_callback = callback.model_copy(
|
||||
update={"data": f"squad_edit_{full_squad_uuid}"}
|
||||
).as_(callback.bot)
|
||||
|
||||
await show_squad_edit_menu(refreshed_callback, db_user, db)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -2285,6 +2342,9 @@ async def show_sync_options(
|
||||
"• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n"
|
||||
"• Рекомендуется делать полную синхронизацию ежедневно\n"
|
||||
"• Баланс пользователей НЕ удаляется\n\n"
|
||||
"⬆️ <b>Обратная синхронизация:</b>\n"
|
||||
"• Отправляет активных пользователей из бота в панель\n"
|
||||
"• Используйте при сбоях панели или для восстановления данных\n\n"
|
||||
+ "\n".join(status_lines)
|
||||
)
|
||||
|
||||
@@ -2295,6 +2355,12 @@ async def show_sync_options(
|
||||
callback_data="sync_all_users",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬆️ Синхронизация в панель",
|
||||
callback_data="sync_to_panel",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⚙️ Настройки автосинхронизации",
|
||||
@@ -2654,6 +2720,50 @@ async def sync_all_users(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_users_to_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"⬆️ Выполняется синхронизация данных бота в панель Remnawave...\n\n"
|
||||
"Это может занять несколько минут.",
|
||||
reply_markup=None,
|
||||
)
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
stats = await remnawave_service.sync_users_to_panel(db)
|
||||
|
||||
if stats["errors"] == 0:
|
||||
status_emoji = "✅"
|
||||
status_text = "успешно завершена"
|
||||
else:
|
||||
status_emoji = "⚠️" if (stats["created"] + stats["updated"]) > 0 else "❌"
|
||||
status_text = "завершена с предупреждениями" if status_emoji == "⚠️" else "завершена с ошибками"
|
||||
|
||||
text = (
|
||||
f"{status_emoji} <b>Синхронизация в панель {status_text}</b>\n\n"
|
||||
"📊 <b>Результаты:</b>\n"
|
||||
f"• 🆕 Создано: {stats['created']}\n"
|
||||
f"• 🔄 Обновлено: {stats['updated']}\n"
|
||||
f"• ❌ Ошибок: {stats['errors']}"
|
||||
)
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="🔄 Повторить", callback_data="sync_to_panel")],
|
||||
[types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
|
||||
[types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")],
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_sync_recommendations(
|
||||
@@ -3126,6 +3236,7 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(cancel_auto_sync_schedule, F.data == "remnawave_auto_sync_cancel")
|
||||
dp.callback_query.register(run_auto_sync_now, F.data == "remnawave_auto_sync_run")
|
||||
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
|
||||
dp.callback_query.register(sync_users_to_panel, F.data == "sync_to_panel")
|
||||
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_"))
|
||||
|
||||
@@ -421,7 +421,8 @@ async def show_server_users(
|
||||
lines = []
|
||||
for index, user in enumerate(page_users, start=start_index + 1):
|
||||
safe_user_name = html.escape(user.full_name)
|
||||
lines.append(f"{index}. {safe_user_name}")
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_user_name}</a>'
|
||||
lines.append(f"{index}. {user_link}")
|
||||
|
||||
text += "\n" + "\n".join(lines)
|
||||
else:
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
import html
|
||||
|
||||
from app.database.models import User, Ticket, TicketStatus
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
@@ -174,12 +175,24 @@ async def view_admin_ticket(
|
||||
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
telegram_id_display = ticket.user.telegram_id if ticket.user else "—"
|
||||
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
|
||||
username_value = ticket.user.username if ticket.user else None
|
||||
|
||||
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
|
||||
ticket_text += f"📱 Username: @{username_display}\n"
|
||||
if username_value:
|
||||
safe_username = html.escape(username_value)
|
||||
ticket_text += f"📱 Username: @{safe_username}\n"
|
||||
ticket_text += (
|
||||
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
|
||||
f"tg://resolve?domain={safe_username}</a>\n"
|
||||
)
|
||||
else:
|
||||
ticket_text += "📱 Username: отсутствует\n"
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
|
||||
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
|
||||
ticket_text += "\n"
|
||||
ticket_text += f"📝 Заголовок: {ticket.title}\n"
|
||||
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
@@ -221,15 +234,11 @@ async def view_admin_ticket(
|
||||
pass
|
||||
# Кнопки ЛС и профиль
|
||||
try:
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
if ticket.user and ticket.user.telegram_id and ticket.user.username:
|
||||
safe_username = html.escape(ticket.user.username)
|
||||
buttons_row = []
|
||||
# DM: при наличии username используем tg://resolve, иначе fallback по ID
|
||||
if ticket.user.username:
|
||||
pm_url = f"tg://resolve?domain={ticket.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
# Профиль: по ID
|
||||
profile_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
if buttons_row:
|
||||
@@ -750,7 +759,21 @@ async def handle_admin_block_duration_input(
|
||||
ticket_text += f"📝 Заголовок: {updated.title}\n"
|
||||
ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
if updated.user and updated.user.telegram_id:
|
||||
ticket_text += f"🆔 Telegram ID: <code>{updated.user.telegram_id}</code>\n"
|
||||
if updated.user.username:
|
||||
safe_username = html.escape(updated.user.username)
|
||||
ticket_text += f"📱 Username: @{safe_username}\n"
|
||||
ticket_text += (
|
||||
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
|
||||
f"tg://resolve?domain={safe_username}</a>\n"
|
||||
)
|
||||
else:
|
||||
ticket_text += "📱 Username: отсутствует\n"
|
||||
chat_link = f"tg://user?id={int(updated.user.telegram_id)}"
|
||||
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
|
||||
ticket_text += "\n"
|
||||
if updated.is_user_reply_blocked:
|
||||
if updated.user_reply_block_permanent:
|
||||
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
|
||||
@@ -778,12 +801,10 @@ async def handle_admin_block_duration_input(
|
||||
pass
|
||||
# Кнопки ЛС и профиль при обновлении карточки
|
||||
try:
|
||||
if updated.user and updated.user.telegram_id:
|
||||
if updated.user and updated.user.telegram_id and updated.user.username:
|
||||
safe_username = html.escape(updated.user.username)
|
||||
buttons_row = []
|
||||
if updated.user.username:
|
||||
pm_url = f"tg://resolve?domain={updated.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
profile_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import logging
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.subscription import (
|
||||
get_trial_statistics,
|
||||
reset_trials_for_users_without_paid_subscription,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_trials_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_trials_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
stats = await get_trial_statistics(db)
|
||||
message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t(
|
||||
"ADMIN_TRIALS_STATS",
|
||||
"• Использовано всего: {used}\n"
|
||||
"• Активно сейчас: {active}\n"
|
||||
"• Доступно к сбросу: {resettable}",
|
||||
).format(
|
||||
used=stats.get("used_trials", 0),
|
||||
active=stats.get("active_trials", 0),
|
||||
resettable=stats.get("resettable_trials", 0),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_admin_trials_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def reset_trials(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
reset_count = await reset_trials_for_users_without_paid_subscription(db)
|
||||
stats = await get_trial_statistics(db)
|
||||
|
||||
message = texts.t(
|
||||
"ADMIN_TRIALS_RESET_RESULT",
|
||||
"♻️ Сбросили {reset_count} триалов.\n\n"
|
||||
"• Использовано всего: {used}\n"
|
||||
"• Активно сейчас: {active}\n"
|
||||
"• Доступно к сбросу: {resettable}",
|
||||
).format(
|
||||
reset_count=reset_count,
|
||||
used=stats.get("used_trials", 0),
|
||||
active=stats.get("active_trials", 0),
|
||||
resettable=stats.get("resettable_trials", 0),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_admin_trials_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен"))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_trials_panel,
|
||||
F.data == "admin_trials",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
reset_trials,
|
||||
F.data == "admin_trials_reset",
|
||||
)
|
||||
@@ -12,6 +12,11 @@ from app.database.crud.user_message import (
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.utils.validators import (
|
||||
get_html_help_text,
|
||||
sanitize_html,
|
||||
validate_html_tags,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -122,8 +127,6 @@ async def add_user_message_start(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.validators import get_html_help_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Добавление нового сообщения</b>\n\n"
|
||||
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
|
||||
@@ -161,8 +164,6 @@ async def process_new_message_text(
|
||||
)
|
||||
return
|
||||
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
is_valid, error_msg = validate_html_tags(message_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
@@ -312,20 +313,22 @@ async def view_user_message(
|
||||
return
|
||||
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
safe_content = sanitize_html(message.message_text)
|
||||
|
||||
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
|
||||
|
||||
|
||||
text = (
|
||||
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
|
||||
f"<b>Статус:</b> {status_text}\n"
|
||||
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Содержимое:</b>\n"
|
||||
f"<blockquote>{message.message_text}</blockquote>"
|
||||
f"<blockquote>{safe_content}</blockquote>"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -455,7 +458,7 @@ async def edit_user_message_start(
|
||||
await callback.message.edit_text(
|
||||
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
|
||||
f"<b>Текущий текст:</b>\n"
|
||||
f"<blockquote>{message.message_text}</blockquote>\n\n"
|
||||
f"<blockquote>{sanitize_html(message.message_text)}</blockquote>\n\n"
|
||||
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -489,14 +492,23 @@ async def process_edit_message_text(
|
||||
return
|
||||
|
||||
new_text = message.text.strip()
|
||||
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
|
||||
"Попробуйте еще раз или отправьте /cancel для отмены."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
is_valid, error_msg = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
|
||||
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
|
||||
parse_mode=None
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
updated_message = await update_user_message(
|
||||
db=db,
|
||||
@@ -511,7 +523,7 @@ async def process_edit_message_text(
|
||||
f"<b>ID:</b> {updated_message.id}\n"
|
||||
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Новый текст:</b>\n"
|
||||
f"<blockquote>{new_text}</blockquote>",
|
||||
f"<blockquote>{sanitize_html(new_text)}</blockquote>",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
+1059
-89
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -18,6 +19,92 @@ from app.database.crud.welcome_text import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Проверяет HTML-теги в тексте на соответствие требованиям Telegram API.
|
||||
|
||||
Args:
|
||||
text: Текст для проверки
|
||||
|
||||
Returns:
|
||||
Кортеж из (валидно ли, сообщение об ошибке или None)
|
||||
"""
|
||||
# Поддерживаемые теги в parse_mode="HTML" для Telegram API
|
||||
allowed_tags = {
|
||||
'b', 'strong', # жирный
|
||||
'i', 'em', # курсив
|
||||
'u', 'ins', # подчеркнуто
|
||||
's', 'strike', 'del', # зачеркнуто
|
||||
'code', # моноширинный для коротких фрагментов
|
||||
'pre', # моноширинный блок кода
|
||||
'a' # ссылки
|
||||
}
|
||||
|
||||
# Убираем плейсхолдеры из строки перед проверкой тегов
|
||||
# Плейсхолдеры имеют формат {ключ}, и не являются тегами
|
||||
placeholder_pattern = r'\{[^{}]+\}'
|
||||
clean_text = re.sub(placeholder_pattern, '', text)
|
||||
|
||||
# Находим все открывающие и закрывающие теги
|
||||
tag_pattern = r'<(/?)([a-zA-Z]+)(\s[^>]*)?>'
|
||||
tags_with_pos = [(m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text)]
|
||||
|
||||
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
|
||||
tag_lower = tag.lower()
|
||||
|
||||
# Проверяем, является ли тег поддерживаемым
|
||||
if tag_lower not in allowed_tags:
|
||||
return False, f"Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {', '.join(sorted(allowed_tags))}"
|
||||
|
||||
# Проверяем атрибуты для тега <a>
|
||||
if tag_lower == 'a':
|
||||
if closing:
|
||||
continue # Для закрывающего тега не нужно проверять атрибуты
|
||||
if not attrs:
|
||||
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
|
||||
|
||||
# Проверяем, что есть атрибут href
|
||||
if 'href=' not in attrs.lower():
|
||||
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
|
||||
|
||||
# Проверяем формат URL
|
||||
href_match = re.search(r'href\s*=\s*[\'"]([^\'"]+)[\'"]', attrs, re.IGNORECASE)
|
||||
if href_match:
|
||||
url = href_match.group(1)
|
||||
# Проверяем, что URL начинается с поддерживаемой схемы
|
||||
if not re.match(r'^https?://|^tg://', url, re.IGNORECASE):
|
||||
return False, f"URL в теге <a> должен начинаться с http://, https:// или tg://. Найдено: {url}"
|
||||
else:
|
||||
return False, "Не удалось извлечь URL из атрибута href тега <a>"
|
||||
|
||||
# Проверяем парность тегов с использованием стека
|
||||
stack = []
|
||||
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
|
||||
tag_lower = tag.lower()
|
||||
|
||||
if tag_lower not in allowed_tags:
|
||||
continue
|
||||
|
||||
if closing:
|
||||
# Это закрывающий тег
|
||||
if not stack:
|
||||
return False, f"Лишний закрывающий тег: </{tag}>"
|
||||
|
||||
last_opening_tag = stack.pop()
|
||||
if last_opening_tag.lower() != tag_lower:
|
||||
return False, f"Тег </{tag}> не соответствует открывающему тегу <{last_opening_tag}>"
|
||||
else:
|
||||
# Это открывающий тег
|
||||
stack.append(tag)
|
||||
|
||||
# Если остались незакрытые теги
|
||||
if stack:
|
||||
unclosed_tags = ", ".join([f"<{tag}>" for tag in stack])
|
||||
return False, f"Незакрытые теги: {unclosed_tags}"
|
||||
|
||||
return True, None
|
||||
|
||||
def get_telegram_formatting_info() -> str:
|
||||
return """
|
||||
📝 <b>Поддерживаемые теги форматирования:</b>
|
||||
@@ -202,6 +289,12 @@ async def process_welcome_text_edit(
|
||||
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
|
||||
return
|
||||
|
||||
# Проверяем HTML-теги на валидность
|
||||
is_valid, error_msg = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(f"❌ Ошибка в HTML-разметке:\n\n{error_msg}")
|
||||
return
|
||||
|
||||
success = await set_welcome_text(db, new_text, db_user.id)
|
||||
|
||||
if success:
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.decorators import error_handler
|
||||
from app.states import BalanceStates
|
||||
@@ -67,7 +68,7 @@ async def start_cryptobot_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
@@ -79,7 +80,12 @@ async def start_cryptobot_payment(
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
|
||||
await state.update_data(
|
||||
payment_method="cryptobot",
|
||||
current_rate=current_rate,
|
||||
cryptobot_prompt_message_id=callback.message.message_id,
|
||||
cryptobot_prompt_chat_id=callback.message.chat.id,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -91,8 +97,26 @@ async def process_cryptobot_payment_amount(
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id,
|
||||
message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
|
||||
try:
|
||||
await message.answer(
|
||||
f"🚫 Оплата невозможна\n\n"
|
||||
f"Причина: {blacklist_reason}\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.is_cryptobot_enabled():
|
||||
await message.answer("❌ Оплата криптовалютой временно недоступна")
|
||||
return
|
||||
@@ -158,8 +182,29 @@ async def process_cryptobot_payment_amount(
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
|
||||
state_data = await state.get_data()
|
||||
prompt_message_id = state_data.get("cryptobot_prompt_message_id")
|
||||
prompt_chat_id = state_data.get("cryptobot_prompt_chat_id", message.chat.id)
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с суммой CryptoBot: %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
if prompt_message_id:
|
||||
try:
|
||||
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||
except Exception as delete_error: # pragma: no cover - diagnostics
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с запросом суммы CryptoBot: %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
invoice_message = await message.answer(
|
||||
f"🪙 <b>Оплата криптовалютой</b>\n\n"
|
||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n"
|
||||
f"💵 К оплате: {amount_usd:.2f} USD\n"
|
||||
@@ -177,7 +222,12 @@ async def process_cryptobot_payment_amount(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
await state.update_data(
|
||||
cryptobot_invoice_message_id=invoice_message.message_id,
|
||||
cryptobot_invoice_chat_id=invoice_message.chat.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_heleket_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_heleket_enabled():
|
||||
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
markup = settings.get_heleket_markup_percent()
|
||||
markup_text: Optional[str]
|
||||
if markup > 0:
|
||||
label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
|
||||
markup_text = f"{label}: {markup:.0f}%"
|
||||
elif markup < 0:
|
||||
label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
|
||||
markup_text = f"{label}: {abs(markup):.0f}%"
|
||||
else:
|
||||
markup_text = None
|
||||
|
||||
message_lines = [
|
||||
"🪙 <b>Пополнение через Heleket</b>",
|
||||
"\n",
|
||||
"Введите сумму пополнения от 100 до 100,000 ₽:",
|
||||
"",
|
||||
"⚡ Мгновенное зачисление", "🔒 Безопасная оплата",
|
||||
]
|
||||
|
||||
if markup_text:
|
||||
message_lines.extend(["", markup_text])
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_buttons:
|
||||
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(filter(None, message_lines)),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(
|
||||
payment_method="heleket",
|
||||
heleket_prompt_message_id=callback.message.message_id,
|
||||
heleket_prompt_chat_id=callback.message.chat.id,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_heleket_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_heleket_enabled():
|
||||
await message.answer("❌ Оплата через Heleket недоступна")
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer("Минимальная сумма пополнения: 100 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
result = await payment_service.create_heleket_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {amount_rubles:.0f} ₽",
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
await message.answer(
|
||||
"❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку."
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = result.get("payment_url")
|
||||
if not payment_url:
|
||||
await message.answer("❌ Не удалось получить ссылку для оплаты Heleket")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payer_amount = result.get("payer_amount")
|
||||
payer_currency = result.get("payer_currency")
|
||||
exchange_rate = result.get("exchange_rate")
|
||||
discount_percent = result.get("discount_percent")
|
||||
|
||||
details = [
|
||||
"🪙 <b>Оплата через Heleket</b>",
|
||||
"",
|
||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽",
|
||||
]
|
||||
|
||||
if payer_amount and payer_currency:
|
||||
details.append(f"🪙 К оплате: {payer_amount} {payer_currency}")
|
||||
|
||||
markup_percent: Optional[float] = None
|
||||
if discount_percent is not None:
|
||||
try:
|
||||
discount_int = int(discount_percent)
|
||||
markup_percent = -discount_int
|
||||
except (TypeError, ValueError):
|
||||
markup_percent = None
|
||||
|
||||
if markup_percent:
|
||||
label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
|
||||
label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
|
||||
absolute = abs(markup_percent)
|
||||
if markup_percent > 0:
|
||||
details.append(f"📈 {label_markup}: +{absolute}%")
|
||||
else:
|
||||
details.append(f"📉 {label_discount}: {absolute}%")
|
||||
|
||||
if payer_amount and payer_currency:
|
||||
try:
|
||||
payer_amount_float = float(payer_amount)
|
||||
if payer_amount_float > 0:
|
||||
rub_per_currency = amount_rubles / payer_amount_float
|
||||
details.append(
|
||||
f"💱 Курс: 1 {payer_currency} ≈ {rub_per_currency:.2f} ₽"
|
||||
)
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
pass
|
||||
|
||||
details.extend(
|
||||
[
|
||||
"",
|
||||
"📱 Инструкция:",
|
||||
"1. Нажмите кнопку 'Оплатить'",
|
||||
"2. Перейдите на страницу Heleket",
|
||||
"3. Оплатите указанную сумму",
|
||||
"4. Баланс пополнится автоматически",
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_heleket_{result['local_payment_id']}"
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
])
|
||||
|
||||
state_data = await state.get_data()
|
||||
prompt_message_id = state_data.get("heleket_prompt_message_id")
|
||||
prompt_chat_id = state_data.get("heleket_prompt_chat_id", message.chat.id)
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||
logger.warning("Не удалось удалить сообщение с суммой Heleket: %s", delete_error)
|
||||
|
||||
if prompt_message_id:
|
||||
try:
|
||||
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с запросом суммы Heleket: %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
invoice_message = await message.answer(
|
||||
"\n".join(details), parse_mode="HTML", reply_markup=keyboard
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services import payment_service as payment_module
|
||||
|
||||
payment = await payment_module.get_heleket_payment_by_id(db, result["local_payment_id"])
|
||||
if payment:
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
metadata["invoice_message"] = {
|
||||
"chat_id": invoice_message.chat.id,
|
||||
"message_id": invoice_message.message_id,
|
||||
}
|
||||
await db.execute(
|
||||
update(payment.__class__)
|
||||
.where(payment.__class__.id == payment.id)
|
||||
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - diagnostics
|
||||
logger.warning("Не удалось сохранить сообщение Heleket: %s", error)
|
||||
|
||||
await state.update_data(
|
||||
heleket_invoice_message_id=invoice_message.message_id,
|
||||
heleket_invoice_chat_id=invoice_message.chat.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_heleket_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.heleket import get_heleket_payment_by_id
|
||||
|
||||
payment = await get_heleket_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
await callback.answer("Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
if payment.is_paid:
|
||||
message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated_payment = await payment_service.sync_heleket_payment_status(
|
||||
db,
|
||||
local_payment_id=local_payment_id,
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
payment = updated_payment
|
||||
|
||||
if payment.is_paid:
|
||||
message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
status_normalized = (payment.status or "").lower()
|
||||
status_messages = {
|
||||
"check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"),
|
||||
"process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"),
|
||||
"confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"),
|
||||
"wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"),
|
||||
"wrong_amount_waiting": texts.t(
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING",
|
||||
"❗️ Недостаточная сумма, ожидаем доплату",
|
||||
),
|
||||
"paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"),
|
||||
"paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"),
|
||||
"cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"),
|
||||
"fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"),
|
||||
"system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"),
|
||||
"refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"),
|
||||
"refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"),
|
||||
"refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"),
|
||||
"locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"),
|
||||
}
|
||||
|
||||
message = status_messages.get(status_normalized)
|
||||
if message is None:
|
||||
template = texts.t("HELEKET_STATUS_UNKNOWN", "ℹ️ Статус платежа: {status}")
|
||||
status_value = payment.status or status_normalized or "—"
|
||||
try:
|
||||
message = template.format(status=status_value)
|
||||
except Exception: # pragma: no cover - defensive formatting
|
||||
message = f"ℹ️ Статус платежа: {status_value}"
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
+287
-28
@@ -1,12 +1,15 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import BalanceStates
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.utils.price_display import calculate_user_price, format_price_button
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.database.crud.transaction import (
|
||||
get_user_transactions, get_user_transactions_count,
|
||||
create_transaction
|
||||
@@ -26,34 +29,61 @@ logger = logging.getLogger(__name__)
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
def get_quick_amount_buttons(language: str) -> list:
|
||||
def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
"""
|
||||
Generate quick amount buttons with user-specific pricing and discounts.
|
||||
|
||||
Args:
|
||||
language: User's language for formatting
|
||||
user: User object to calculate personalized discounts
|
||||
|
||||
Returns:
|
||||
List of button rows for inline keyboard
|
||||
"""
|
||||
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
|
||||
return []
|
||||
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(language)
|
||||
|
||||
buttons = []
|
||||
periods = settings.get_available_subscription_periods()
|
||||
|
||||
periods = periods[:6]
|
||||
|
||||
periods = periods[:6] # Limit to 6 periods
|
||||
|
||||
for period in periods:
|
||||
price_attr = f"PRICE_{period}_DAYS"
|
||||
if hasattr(settings, price_attr):
|
||||
price_kopeks = getattr(settings, price_attr)
|
||||
price_rubles = price_kopeks // 100
|
||||
|
||||
callback_data = f"quick_amount_{price_kopeks}"
|
||||
|
||||
base_price_kopeks = getattr(settings, price_attr)
|
||||
|
||||
# Calculate price with user's promo group discount using unified system
|
||||
price_info = calculate_user_price(user, base_price_kopeks, period, "period")
|
||||
|
||||
callback_data = f"quick_amount_{price_info.final_price}"
|
||||
|
||||
# Format button text with discount display
|
||||
period_label = f"{period} дней"
|
||||
|
||||
# For balance buttons, use simpler format without emoji and period label prefix
|
||||
if price_info.has_discount:
|
||||
button_text = (
|
||||
f"{texts.format_price(price_info.base_price)} ➜ "
|
||||
f"{texts.format_price(price_info.final_price)} "
|
||||
f"(-{price_info.discount_percent}%) • {period_label}"
|
||||
)
|
||||
else:
|
||||
button_text = f"{texts.format_price(price_info.final_price)} • {period_label}"
|
||||
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{price_rubles} ₽ ({period} дней)",
|
||||
text=button_text,
|
||||
callback_data=callback_data
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i:i + 2])
|
||||
|
||||
|
||||
return keyboard_rows
|
||||
|
||||
|
||||
@@ -64,15 +94,38 @@ async def show_balance_menu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
balance_text = texts.BALANCE_INFO.format(
|
||||
balance=texts.format_price(db_user.balance_kopeks)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=get_balance_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
reply_markup = get_balance_keyboard(db_user.language)
|
||||
|
||||
try:
|
||||
if callback.message and callback.message.text:
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
elif callback.message and callback.message.caption:
|
||||
await callback.message.edit_caption(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
else:
|
||||
await callback.message.answer(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
except TelegramBadRequest as error:
|
||||
logger.warning(
|
||||
"Failed to edit balance message, sending a new one instead: %s",
|
||||
error,
|
||||
)
|
||||
await callback.message.answer(
|
||||
balance_text,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -179,18 +232,126 @@ async def handle_balance_history_pagination(
|
||||
async def show_payment_methods(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.utils.pricing_utils import calculate_months_from_days, apply_percentage_discount
|
||||
from app.config import settings
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
reply_markup=get_payment_methods_keyboard(0, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Добавляем информацию о текущем тарифе пользователя
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
tariff_info = ""
|
||||
if subscription and not subscription.is_trial:
|
||||
# Рассчитываем приблизительную стоимость продления на 30 дней
|
||||
duration_days = 30 # Берем для примера 30 дней
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
current_connected_squads = subscription.connected_squads or []
|
||||
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
try:
|
||||
# Получаем цены для текущих параметров
|
||||
from app.config import PERIOD_PRICES
|
||||
base_price_original = PERIOD_PRICES.get(duration_days, 0)
|
||||
period_discount_percent = db_user.get_promo_discount("period", duration_days)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
# Рассчитываем стоимость серверов
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
|
||||
current_connected_squads,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_discount_percent = db_user.get_promo_discount("servers", duration_days)
|
||||
total_servers_price = 0
|
||||
for server_price in per_server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_servers_price += discounted_per_month
|
||||
|
||||
# Рассчитываем стоимость трафика
|
||||
traffic_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
traffic_discount_percent = db_user.get_promo_discount("traffic", duration_days)
|
||||
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
|
||||
# Рассчитываем стоимость устройств
|
||||
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount("devices", duration_days)
|
||||
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
|
||||
# Общая стоимость
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
total_price = (
|
||||
base_price +
|
||||
total_servers_price * months_in_period +
|
||||
traffic_discounted_per_month * months_in_period +
|
||||
devices_discounted_per_month * months_in_period
|
||||
)
|
||||
|
||||
traffic_value = current_traffic or 0
|
||||
if traffic_value <= 0:
|
||||
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
|
||||
else:
|
||||
traffic_display = texts.format_traffic(traffic_value)
|
||||
|
||||
current_tariff_desc = (
|
||||
f"📱 Подписка: {len(current_connected_squads)} серверов, "
|
||||
f"{traffic_display}, {current_device_limit} устр."
|
||||
)
|
||||
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
|
||||
|
||||
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}")
|
||||
tariff_info = ""
|
||||
|
||||
full_text = payment_text + tariff_info
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.edit_caption(
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -257,6 +418,10 @@ async def handle_successful_topup_with_cart(
|
||||
success_text = (
|
||||
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
|
||||
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
f"🛒 У вас есть сохраненная корзина подписки\n"
|
||||
f"Стоимость: {texts.format_price(total_price)}\n\n"
|
||||
f"Хотите продолжить оформление?"
|
||||
@@ -279,7 +444,17 @@ async def request_support_topup(
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.is_support_topup_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"SUPPORT_TOPUP_DISABLED",
|
||||
"Пополнение через поддержку отключено. Попробуйте другой способ оплаты.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
support_text = f"""
|
||||
🛠️ <b>Пополнение через поддержку</b>
|
||||
|
||||
@@ -390,6 +565,14 @@ async def process_topup_amount(
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
@@ -406,6 +589,11 @@ async def process_topup_amount(
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
@@ -496,6 +684,14 @@ async def handle_quick_amount_selection(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
@@ -519,6 +715,14 @@ async def handle_quick_amount_selection(
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
|
||||
@@ -575,6 +779,21 @@ async def handle_topup_amount_callback(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount, start_platega_payment
|
||||
|
||||
data = await state.get_data()
|
||||
method_code = int(data.get("platega_method", 0)) if data else 0
|
||||
|
||||
if method_code > 0:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await state.update_data(platega_pending_amount=amount_kopeks)
|
||||
await start_platega_payment(callback, db_user, state)
|
||||
elif method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
@@ -589,6 +808,20 @@ async def handle_topup_amount_callback(
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(
|
||||
@@ -672,6 +905,16 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("pal24_method_"),
|
||||
)
|
||||
|
||||
from .platega import start_platega_payment, handle_platega_method_selection
|
||||
dp.callback_query.register(
|
||||
start_platega_payment,
|
||||
F.data == "topup_platega"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_platega_method_selection,
|
||||
F.data.startswith("platega_method_"),
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
@@ -712,6 +955,16 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_cryptobot_")
|
||||
)
|
||||
|
||||
from .heleket import start_heleket_payment, check_heleket_payment_status
|
||||
dp.callback_query.register(
|
||||
start_heleket_payment,
|
||||
F.data == "topup_heleket"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
check_heleket_payment_status,
|
||||
F.data.startswith("check_heleket_")
|
||||
)
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
dp.callback_query.register(
|
||||
check_mulenpay_payment_status,
|
||||
@@ -730,6 +983,12 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_pal24_")
|
||||
)
|
||||
|
||||
from .platega import check_platega_payment_status
|
||||
dp.callback_query.register(
|
||||
check_platega_payment_status,
|
||||
F.data.startswith("check_platega_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
@@ -744,4 +1003,4 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
handle_topup_amount_callback,
|
||||
F.data.startswith("topup_amount|")
|
||||
)
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ async def start_mulenpay_payment(
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
@@ -59,7 +59,11 @@ async def start_mulenpay_payment(
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="mulenpay")
|
||||
await state.update_data(
|
||||
payment_method="mulenpay",
|
||||
mulenpay_prompt_message_id=callback.message.message_id,
|
||||
mulenpay_prompt_chat_id=callback.message.chat.id,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -93,6 +97,26 @@ async def process_mulenpay_payment_amount(
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
state_data = await state.get_data()
|
||||
prompt_message_id = state_data.get("mulenpay_prompt_message_id")
|
||||
prompt_chat_id = state_data.get("mulenpay_prompt_chat_id", message.chat.id)
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as delete_error: # pragma: no cover - depends on bot permissions
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с суммой MulenPay: %s", delete_error
|
||||
)
|
||||
|
||||
if prompt_message_id:
|
||||
try:
|
||||
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с запросом суммы MulenPay: %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_mulenpay_payment(
|
||||
@@ -163,12 +187,39 @@ async def process_mulenpay_payment_amount(
|
||||
mulenpay_name_html=mulenpay_name_html,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
invoice_message = await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services import payment_service as payment_module
|
||||
|
||||
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
if payment:
|
||||
payment_metadata = dict(
|
||||
getattr(payment, "metadata_json", {}) or {}
|
||||
)
|
||||
payment_metadata["invoice_message"] = {
|
||||
"chat_id": invoice_message.chat.id,
|
||||
"message_id": invoice_message.message_id,
|
||||
}
|
||||
await payment_module.update_mulenpay_payment_metadata(
|
||||
db,
|
||||
payment=payment,
|
||||
metadata=payment_metadata,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - diagnostic logging only
|
||||
logger.warning("Не удалось сохранить данные сообщения MulenPay: %s", error)
|
||||
|
||||
await state.update_data(
|
||||
mulenpay_invoice_message_id=invoice_message.message_id,
|
||||
mulenpay_invoice_chat_id=invoice_message.chat.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -202,12 +206,36 @@ async def _send_pal24_payment_message(
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
invoice_message = await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services import payment_service as payment_module
|
||||
|
||||
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
|
||||
if payment:
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
metadata["invoice_message"] = {
|
||||
"chat_id": invoice_message.chat.id,
|
||||
"message_id": invoice_message.message_id,
|
||||
}
|
||||
await db.execute(
|
||||
update(payment.__class__)
|
||||
.where(payment.__class__.id == payment.id)
|
||||
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - diagnostics
|
||||
logger.warning("Не удалось сохранить сообщение PayPalych: %s", error)
|
||||
|
||||
await state.update_data(
|
||||
pal24_invoice_message_id=invoice_message.message_id,
|
||||
pal24_invoice_chat_id=invoice_message.chat.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
@@ -264,7 +292,7 @@ async def start_pal24_payment(
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
@@ -275,7 +303,11 @@ async def start_pal24_payment(
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await state.update_data(
|
||||
payment_method="pal24",
|
||||
pal24_prompt_message_id=callback.message.message_id,
|
||||
pal24_prompt_chat_id=callback.message.chat.id,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -307,6 +339,24 @@ async def process_pal24_payment_amount(
|
||||
|
||||
available_methods = _get_available_pal24_methods()
|
||||
|
||||
state_data = await state.get_data()
|
||||
prompt_message_id = state_data.get("pal24_prompt_message_id")
|
||||
prompt_chat_id = state_data.get("pal24_prompt_chat_id", message.chat.id)
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||
logger.warning("Не удалось удалить сообщение с суммой PayPalych: %s", delete_error)
|
||||
|
||||
if prompt_message_id:
|
||||
try:
|
||||
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с запросом суммы PayPalych: %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
if len(available_methods) == 1:
|
||||
await _send_pal24_payment_message(
|
||||
message,
|
||||
@@ -423,8 +473,77 @@ async def check_pal24_payment_status(
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
links_info = status_info.get("links") or {}
|
||||
|
||||
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
|
||||
stack: list[Any] = [source]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
for key in keys:
|
||||
value = current.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
stack.extend(current.values())
|
||||
elif isinstance(current, list):
|
||||
stack.extend(current)
|
||||
return None
|
||||
|
||||
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
|
||||
remote_data = status_info.get("remote_data")
|
||||
transfer_keys = (
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_keys = (
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
|
||||
extra_sbp_link = (
|
||||
_extract_link(raw_response, transfer_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_sbp_link and remote_data:
|
||||
extra_sbp_link = _extract_link(remote_data, transfer_keys)
|
||||
|
||||
extra_card_link = (
|
||||
_extract_link(raw_response, card_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_card_link and remote_data:
|
||||
extra_card_link = _extract_link(remote_data, card_keys)
|
||||
|
||||
sbp_link = (
|
||||
links_info.get("sbp")
|
||||
or links_meta.get("sbp")
|
||||
or status_info.get("sbp_url")
|
||||
or extra_sbp_link
|
||||
or payment.link_url
|
||||
)
|
||||
card_link = (
|
||||
links_info.get("card")
|
||||
or links_meta.get("card")
|
||||
or status_info.get("card_url")
|
||||
or extra_card_link
|
||||
)
|
||||
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
@@ -459,10 +578,53 @@ async def check_pal24_payment_status(
|
||||
db_user = getattr(callback, 'db_user', None)
|
||||
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), callback_data=f"check_pal24_{local_payment_id}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
pay_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_link and settings.is_pal24_sbp_button_visible():
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_link and settings.is_pal24_card_button_visible():
|
||||
if not pay_rows or pay_rows[-1][0].url != card_link:
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows = pay_rows + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.answer()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Handlers for Platega balance interactions."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_active_methods() -> List[int]:
|
||||
methods = settings.get_platega_active_methods()
|
||||
return [code for code in methods if code in {2, 10, 11, 12, 13}]
|
||||
|
||||
|
||||
async def _prompt_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
method_code: int,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
method_name = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
# Всегда фиксируем выбранный метод для последующей обработки
|
||||
await state.update_data(payment_method="platega", platega_method=method_code)
|
||||
|
||||
data = await state.get_data()
|
||||
pending_amount = int(data.get("platega_pending_amount") or 0)
|
||||
|
||||
if pending_amount > 0:
|
||||
# Если сумма уже известна (например, после быстрого выбора),
|
||||
# сразу создаём платеж и сбрасываем временное значение.
|
||||
await state.update_data(platega_pending_amount=None)
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
message,
|
||||
db_user,
|
||||
db,
|
||||
pending_amount,
|
||||
state,
|
||||
)
|
||||
return
|
||||
|
||||
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
|
||||
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
||||
max_amount_label = (
|
||||
settings.format_price(max_amount_kopeks)
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
default_prompt_body = (
|
||||
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else "Введите сумму для пополнения от {min_amount}.\n"
|
||||
)
|
||||
|
||||
prompt_template = texts.t(
|
||||
"PLATEGA_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
|
||||
f"{default_prompt_body}"
|
||||
"Оплата происходит через Platega."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await message.edit_text(
|
||||
prompt_template.format(
|
||||
method_name=method_name,
|
||||
min_amount=min_amount_label,
|
||||
max_amount=max_amount_label,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(
|
||||
platega_prompt_message_id=message.message_id,
|
||||
platega_prompt_chat_id=message.chat.id,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_platega_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
active_methods = _get_active_methods()
|
||||
if not active_methods:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED",
|
||||
"⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(payment_method="platega")
|
||||
data = await state.get_data()
|
||||
has_pending_amount = bool(int(data.get("platega_pending_amount") or 0))
|
||||
|
||||
if len(active_methods) == 1:
|
||||
await _prompt_amount(callback.message, db_user, state, active_methods[0])
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
method_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for method_code in active_methods:
|
||||
label = settings.get_platega_method_display_title(method_code)
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=f"platega_method_{method_code}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
method_buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD",
|
||||
"Выберите способ оплаты Platega:",
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||
)
|
||||
if not has_pending_amount:
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_platega_method_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
method_code = int(callback.data.rsplit("_", 1)[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
if method_code not in _get_active_methods():
|
||||
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
|
||||
return
|
||||
|
||||
await _prompt_amount(callback.message, db_user, state, method_code)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_platega_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
method_code = int(data.get("platega_method", 0))
|
||||
if method_code not in _get_active_methods():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED",
|
||||
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
|
||||
)
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method_code=method_code,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка создания платежа Platega: %s", error)
|
||||
payment_result = None
|
||||
|
||||
if not payment_result or not payment_result.get("redirect_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
redirect_url = payment_result.get("redirect_url")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
transaction_id = payment_result.get("transaction_id")
|
||||
method_title = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PLATEGA_PAY_BUTTON",
|
||||
"💳 Оплатить через {method}",
|
||||
).format(method=method_title),
|
||||
url=redirect_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_platega_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
instructions_template = texts.t(
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method})</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID транзакции: {transaction}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку «Оплатить»\n"
|
||||
"2. Следуйте подсказкам платёжной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
state_data = await state.get_data()
|
||||
prompt_message_id = state_data.get("platega_prompt_message_id")
|
||||
prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id)
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||
logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error)
|
||||
|
||||
if prompt_message_id:
|
||||
try:
|
||||
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с запросом суммы Platega: %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
invoice_message = await message.answer(
|
||||
instructions_template.format(
|
||||
method=method_title,
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
transaction=transaction_id or local_payment_id,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services import payment_service as payment_module
|
||||
|
||||
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
|
||||
if payment:
|
||||
payment_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
payment_metadata["invoice_message"] = {
|
||||
"chat_id": invoice_message.chat.id,
|
||||
"message_id": invoice_message.message_id,
|
||||
}
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
metadata=payment_metadata,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.warning("Не удалось сохранить данные сообщения Platega: %s", error)
|
||||
|
||||
await state.update_data(
|
||||
platega_invoice_message_id=invoice_message.message_id,
|
||||
platega_invoice_chat_id=invoice_message.chat.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_platega_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
try:
|
||||
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка проверки статуса Platega: %s", error)
|
||||
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
|
||||
return
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("⚠️ Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info.get("payment")
|
||||
status = status_info.get("status")
|
||||
is_paid = status_info.get("is_paid")
|
||||
|
||||
language = "ru"
|
||||
user = getattr(payment, "user", None)
|
||||
if user and getattr(user, "language", None):
|
||||
language = user.language
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
if is_paid:
|
||||
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
|
||||
else:
|
||||
await callback.answer(
|
||||
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
|
||||
show_alert=True,
|
||||
)
|
||||
@@ -1,12 +1,12 @@
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -22,11 +22,11 @@ async def start_stars_payment(
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
message_text = (
|
||||
@@ -35,23 +35,28 @@ async def start_stars_payment(
|
||||
)
|
||||
else:
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
|
||||
await state.update_data(
|
||||
stars_prompt_message_id=callback.message.message_id,
|
||||
stars_prompt_chat_id=callback.message.chat.id,
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="stars")
|
||||
await callback.answer()
|
||||
@@ -64,30 +69,67 @@ async def process_stars_payment_amount(
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id,
|
||||
message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
|
||||
try:
|
||||
await message.answer(
|
||||
f"🚫 Оплата невозможна\n\n"
|
||||
f"Причина: {blacklist_reason}\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("⚠️ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
|
||||
state_data = await state.get_data()
|
||||
|
||||
prompt_message_id = state_data.get("stars_prompt_message_id")
|
||||
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||
logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error)
|
||||
|
||||
if prompt_message_id:
|
||||
try:
|
||||
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с запросом суммы Stars: %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
invoice_message = await message.answer(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
@@ -96,9 +138,14 @@ async def process_stars_payment_amount(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
await state.update_data(
|
||||
stars_invoice_message_id=invoice_message.message_id,
|
||||
stars_invoice_chat_id=invoice_message.chat.id,
|
||||
)
|
||||
|
||||
await state.set_state(None)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("⚠️ Ошибка создания платежа")
|
||||
await message.answer("⚠️ Ошибка создания платежа")
|
||||
|
||||
@@ -13,47 +13,59 @@ logger = logging.getLogger(__name__)
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
db_user: User,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
|
||||
tribute_service = TributeService(callback.bot)
|
||||
payment_url = await tribute_service.create_payment_link(
|
||||
user_id=db_user.telegram_id,
|
||||
amount_kopeks=0,
|
||||
description="Пополнение баланса VPN"
|
||||
description="Пополнение баланса VPN",
|
||||
)
|
||||
|
||||
|
||||
if not payment_url:
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💳 <b>Пополнение банковской картой</b>\n\n"
|
||||
f"• Введите любую сумму от 100₽\n"
|
||||
f"• Безопасная оплата через Tribute\n"
|
||||
f"• Мгновенное зачисление на баланс\n"
|
||||
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
||||
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
||||
f"Нажмите кнопку для перехода к оплате:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
message_text = (
|
||||
"💳 <b>Пополнение банковской картой</b>\n\n"
|
||||
"• Введите любую сумму от 100₽\n"
|
||||
"• Безопасная оплата через Tribute\n"
|
||||
"• Мгновенное зачисление на баланс\n"
|
||||
"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
||||
"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
||||
"Нажмите кнопку для перехода к оплате:"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
TributeService.remember_invoice_message(
|
||||
db_user.telegram_id,
|
||||
callback.message.chat.id,
|
||||
callback.message.message_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -45,7 +47,7 @@ async def start_wata_payment(
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
@@ -56,7 +58,11 @@ async def start_wata_payment(
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="wata")
|
||||
await state.update_data(
|
||||
payment_method="wata",
|
||||
wata_prompt_message_id=callback.message.message_id,
|
||||
wata_prompt_chat_id=callback.message.chat.id,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -159,12 +165,54 @@ async def process_wata_payment_amount(
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
state_data = await state.get_data()
|
||||
prompt_message_id = state_data.get("wata_prompt_message_id")
|
||||
prompt_chat_id = state_data.get("wata_prompt_chat_id", message.chat.id)
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||
logger.warning("Не удалось удалить сообщение с суммой WATA: %s", delete_error)
|
||||
|
||||
if prompt_message_id:
|
||||
try:
|
||||
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с запросом суммы WATA: %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
invoice_message = await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services import payment_service as payment_module
|
||||
|
||||
payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id)
|
||||
if payment:
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
metadata["invoice_message"] = {
|
||||
"chat_id": invoice_message.chat.id,
|
||||
"message_id": invoice_message.message_id,
|
||||
}
|
||||
await db.execute(
|
||||
update(payment.__class__)
|
||||
.where(payment.__class__.id == payment.id)
|
||||
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - diagnostics
|
||||
logger.warning("Не удалось сохранить сообщение WATA: %s", error)
|
||||
|
||||
await state.update_data(
|
||||
wata_invoice_message_id=invoice_message.message_id,
|
||||
wata_invoice_chat_id=invoice_message.chat.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.decorators import error_handler
|
||||
from app.states import BalanceStates
|
||||
@@ -48,7 +52,7 @@ async def start_yookassa_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
@@ -58,9 +62,13 @@ async def start_yookassa_payment(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="yookassa")
|
||||
await state.update_data(
|
||||
yookassa_prompt_message_id=callback.message.message_id,
|
||||
yookassa_prompt_chat_id=callback.message.chat.id,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -98,7 +106,7 @@ async def start_yookassa_sbp_payment(
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
@@ -108,9 +116,13 @@ async def start_yookassa_sbp_payment(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="yookassa_sbp")
|
||||
await state.update_data(
|
||||
yookassa_prompt_message_id=callback.message.message_id,
|
||||
yookassa_prompt_chat_id=callback.message.chat.id,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -122,8 +134,26 @@ async def process_yookassa_payment_amount(
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id,
|
||||
message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
|
||||
try:
|
||||
await message.answer(
|
||||
f"🚫 Оплата невозможна\n\n"
|
||||
f"Причина: {blacklist_reason}\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
await message.answer("❌ Оплата через YooKassa временно недоступна")
|
||||
return
|
||||
@@ -145,7 +175,7 @@ async def process_yookassa_payment_amount(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
@@ -172,7 +202,25 @@ async def process_yookassa_payment_amount(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
state_data = await state.get_data()
|
||||
prompt_message_id = state_data.get("yookassa_prompt_message_id")
|
||||
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||
logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error)
|
||||
|
||||
if prompt_message_id:
|
||||
try:
|
||||
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с запросом суммы YooKassa: %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
invoice_message = await message.answer(
|
||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
@@ -187,9 +235,34 @@ async def process_yookassa_payment_amount(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
from app.services import payment_service as payment_module
|
||||
|
||||
payment = await payment_module.get_yookassa_payment_by_local_id(
|
||||
db, payment_result["local_payment_id"]
|
||||
)
|
||||
if payment:
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
metadata["invoice_message"] = {
|
||||
"chat_id": invoice_message.chat.id,
|
||||
"message_id": invoice_message.message_id,
|
||||
}
|
||||
await db.execute(
|
||||
update(payment.__class__)
|
||||
.where(payment.__class__.id == payment.id)
|
||||
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.warning("Не удалось сохранить сообщение YooKassa: %s", error)
|
||||
|
||||
await state.update_data(
|
||||
yookassa_invoice_message_id=invoice_message.message_id,
|
||||
yookassa_invoice_chat_id=invoice_message.chat.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
|
||||
@@ -207,8 +280,26 @@ async def process_yookassa_sbp_payment_amount(
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id,
|
||||
message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
|
||||
try:
|
||||
await message.answer(
|
||||
f"🚫 Оплата невозможна\n\n"
|
||||
f"Причина: {blacklist_reason}\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
|
||||
await message.answer("❌ Оплата через СБП временно недоступна")
|
||||
return
|
||||
@@ -230,7 +321,7 @@ async def process_yookassa_sbp_payment_amount(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
@@ -310,27 +401,45 @@ async def process_yookassa_sbp_payment_amount(
|
||||
|
||||
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
|
||||
keyboard_buttons = []
|
||||
|
||||
|
||||
# Добавляем кнопку оплаты, если доступна ссылка
|
||||
if confirmation_url:
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
|
||||
else:
|
||||
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
|
||||
|
||||
|
||||
# Добавляем общие кнопки
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
||||
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
|
||||
state_data = await state.get_data()
|
||||
prompt_message_id = state_data.get("yookassa_prompt_message_id")
|
||||
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
|
||||
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||
logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error)
|
||||
|
||||
if prompt_message_id:
|
||||
try:
|
||||
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
# Подготавливаем текст сообщения
|
||||
message_text = (
|
||||
f"🔗 <b>Оплата через СБП</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
)
|
||||
|
||||
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
if not confirmation_url:
|
||||
message_text += (
|
||||
@@ -341,18 +450,18 @@ async def process_yookassa_sbp_payment_amount(
|
||||
f"4. Подтвердите платеж в приложении банка\n"
|
||||
f"5. Деньги поступят на баланс автоматически\n\n"
|
||||
)
|
||||
|
||||
|
||||
message_text += (
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
||||
)
|
||||
|
||||
|
||||
# Отправляем сообщение с инструкциями и клавиатурой
|
||||
# Если есть QR-код, отправляем его как медиа-сообщение
|
||||
if qr_photo:
|
||||
# Используем метод отправки медиа-группы или фото с описанием
|
||||
await message.answer_photo(
|
||||
invoice_message = await message.answer_photo(
|
||||
photo=qr_photo,
|
||||
caption=message_text,
|
||||
reply_markup=keyboard,
|
||||
@@ -360,12 +469,39 @@ async def process_yookassa_sbp_payment_amount(
|
||||
)
|
||||
else:
|
||||
# Если QR-код недоступен, отправляем обычное текстовое сообщение
|
||||
await message.answer(
|
||||
invoice_message = await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
from app.services import payment_service as payment_module
|
||||
|
||||
payment = await payment_module.get_yookassa_payment_by_local_id(
|
||||
db, payment_result["local_payment_id"]
|
||||
)
|
||||
if payment:
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
metadata["invoice_message"] = {
|
||||
"chat_id": invoice_message.chat.id,
|
||||
"message_id": invoice_message.message_id,
|
||||
}
|
||||
await db.execute(
|
||||
update(payment.__class__)
|
||||
.where(payment.__class__.id == payment.id)
|
||||
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error)
|
||||
|
||||
await state.update_data(
|
||||
yookassa_invoice_message_id=invoice_message.message_id,
|
||||
yookassa_invoice_chat_id=invoice_message.chat.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
get_active_rounds,
|
||||
get_attempt,
|
||||
create_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.contest_rotation_service import (
|
||||
GAME_QUEST,
|
||||
GAME_LOCKS,
|
||||
GAME_CIPHER,
|
||||
GAME_SERVER,
|
||||
GAME_BLITZ,
|
||||
GAME_EMOJI,
|
||||
GAME_ANAGRAM,
|
||||
)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.utils.decorators import auth_required, error_handler
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.states import ContestStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _user_allowed(subscription) -> bool:
|
||||
if not subscription:
|
||||
return False
|
||||
return subscription.status in {
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
}
|
||||
|
||||
|
||||
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return ""
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return ""
|
||||
await extend_subscription(db, subscription, prize_days)
|
||||
texts = get_texts(language)
|
||||
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
|
||||
|
||||
|
||||
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
|
||||
texts = get_texts(language)
|
||||
await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
|
||||
|
||||
|
||||
# ---------- Handlers ----------
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await _reply_not_eligible(callback, db_user.language)
|
||||
return
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
unique_templates = {}
|
||||
for rnd in active_rounds:
|
||||
if not rnd.template or not rnd.template.is_enabled:
|
||||
continue
|
||||
tpl_slug = rnd.template.slug if rnd.template else ""
|
||||
if tpl_slug not in unique_templates:
|
||||
unique_templates[tpl_slug] = rnd
|
||||
|
||||
buttons = []
|
||||
for tpl_slug, rnd in unique_templates.items():
|
||||
title = rnd.template.name if rnd.template else tpl_slug
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"▶️ {title}",
|
||||
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
if not buttons:
|
||||
buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")]
|
||||
)
|
||||
buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_MENU_TITLE", "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await _reply_not_eligible(callback, db_user.language)
|
||||
return
|
||||
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "play":
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
round_id_str = parts[-1]
|
||||
try:
|
||||
round_id = int(round_id_str)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
slug = "_".join(parts[2:-1])
|
||||
|
||||
# reload round with template
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True)
|
||||
return
|
||||
if not round_obj.template or not round_obj.template.is_enabled:
|
||||
await callback.answer(texts.t("CONTEST_DISABLED", "Игра отключена."), show_alert=True)
|
||||
return
|
||||
attempt = await get_attempt(db2, round_id, db_user.id)
|
||||
if attempt:
|
||||
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True)
|
||||
return
|
||||
|
||||
tpl = round_obj.template
|
||||
if tpl.slug == GAME_QUEST:
|
||||
await _render_quest(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_LOCKS:
|
||||
await _render_locks(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_SERVER:
|
||||
await _render_server_lottery(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_CIPHER:
|
||||
await _render_cipher(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_EMOJI:
|
||||
await _render_emoji(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_ANAGRAM:
|
||||
await _render_anagram(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
await _render_blitz(callback, db_user, round_obj, tpl)
|
||||
else:
|
||||
await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True)
|
||||
|
||||
|
||||
async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
rows = round_obj.payload.get("rows", 3)
|
||||
cols = round_obj.payload.get("cols", 3)
|
||||
keyboard = []
|
||||
for r in range(rows):
|
||||
row_buttons = []
|
||||
for c in range(cols):
|
||||
idx = r * cols + c
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="🎛",
|
||||
callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
|
||||
)
|
||||
)
|
||||
keyboard.append(row_buttons)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
total = round_obj.payload.get("total", 20)
|
||||
keyboard = []
|
||||
row = []
|
||||
for i in range(total):
|
||||
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
shuffled_flags = flags.copy()
|
||||
random.shuffle(shuffled_flags)
|
||||
keyboard = []
|
||||
row = []
|
||||
for flag in shuffled_flags:
|
||||
row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{flag}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
question = round_obj.payload.get("question", "")
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
question = round_obj.payload.get("question", "🤔")
|
||||
emoji_list = question.split()
|
||||
random.shuffle(emoji_list)
|
||||
shuffled_question = " ".join(emoji_list)
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=shuffled_question),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
letters = round_obj.payload.get("letters", "")
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "pick":
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
round_id_str = parts[2]
|
||||
pick = "_".join(parts[3:])
|
||||
try:
|
||||
round_id = int(round_id_str)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True)
|
||||
return
|
||||
|
||||
tpl = round_obj.template
|
||||
attempt = await get_attempt(db2, round_id, db_user.id)
|
||||
if attempt:
|
||||
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True)
|
||||
return
|
||||
|
||||
secret_idx = round_obj.payload.get("secret_idx")
|
||||
correct_flag = ""
|
||||
if tpl.slug == GAME_SERVER:
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
|
||||
|
||||
is_winner = False
|
||||
if tpl.slug == GAME_SERVER:
|
||||
is_winner = pick == correct_flag
|
||||
elif tpl.slug == GAME_QUEST:
|
||||
# Format: quest_{idx}
|
||||
try:
|
||||
if pick.startswith("quest_"):
|
||||
idx = int(pick.split("_")[1])
|
||||
is_winner = secret_idx is not None and idx == secret_idx
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_LOCKS:
|
||||
# Format: locks_{idx}
|
||||
try:
|
||||
if pick.startswith("locks_"):
|
||||
idx = int(pick.split("_")[1])
|
||||
is_winner = secret_idx is not None and idx == secret_idx
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
is_winner = pick == "blitz"
|
||||
else:
|
||||
is_winner = False
|
||||
|
||||
# Check if max winners already reached
|
||||
if is_winner and round_obj.winners_count >= round_obj.max_winners:
|
||||
is_winner = False # Too late, max winners already reached
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db2, round_obj)
|
||||
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
|
||||
await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True)
|
||||
else:
|
||||
responses = {
|
||||
GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"],
|
||||
GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"],
|
||||
GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"],
|
||||
}.get(tpl.slug, ["Неудача"])
|
||||
await callback.answer(random.choice(responses), show_alert=True)
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
round_id = data.get("contest_round_id")
|
||||
if not round_id:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
attempt = await get_attempt(db2, round_obj.id, db_user.id)
|
||||
if attempt:
|
||||
await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
answer = (message.text or "").strip().upper()
|
||||
tpl = round_obj.template
|
||||
correct = (round_obj.payload.get("answer") or "").upper()
|
||||
|
||||
is_winner = correct and answer == correct
|
||||
|
||||
# Check if max winners already reached
|
||||
if is_winner and round_obj.winners_count >= round_obj.max_winners:
|
||||
is_winner = False # Too late, max winners already reached
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db2, round_obj)
|
||||
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
|
||||
await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language))
|
||||
else:
|
||||
await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
|
||||
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
|
||||
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
|
||||
dp.message.register(handle_text_answer, ContestStates.waiting_for_answer)
|
||||
dp.message.register(lambda message: None, Command("contests")) # placeholder
|
||||
+279
-37
@@ -17,6 +17,7 @@ from app.database.crud.promo_group import (
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.keyboards.inline import (
|
||||
get_main_menu_keyboard,
|
||||
get_main_menu_keyboard_async,
|
||||
get_language_selection_keyboard,
|
||||
get_info_menu_keyboard,
|
||||
)
|
||||
@@ -38,7 +39,8 @@ 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
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.handlers.subscription.traffic import handle_add_traffic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -148,6 +150,18 @@ async def show_main_menu(
|
||||
*,
|
||||
skip_callback_answer: bool = False,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
@@ -163,7 +177,7 @@ async def show_main_menu(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
|
||||
# Проверяем наличие сохраненной корзины в Redis
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
|
||||
@@ -185,22 +199,26 @@ async def show_main_menu(
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=db_user,
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
force_text=settings.is_text_main_menu_mode(),
|
||||
)
|
||||
@@ -229,6 +247,18 @@ async def show_service_rules(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -251,6 +281,18 @@ async def show_info_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
header = texts.t("MENU_INFO_HEADER", "ℹ️ <b>Инфо</b>")
|
||||
@@ -282,6 +324,18 @@ async def show_promo_groups_info(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
promo_groups = await get_auto_assign_promo_groups(db)
|
||||
@@ -422,6 +476,18 @@ async def show_faq_pages(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
pages = await FaqService.get_pages(db, db_user.language)
|
||||
@@ -457,6 +523,7 @@ async def show_faq_pages(
|
||||
await callback.message.edit_text(
|
||||
caption,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -466,6 +533,18 @@ async def show_faq_page(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_data = callback.data or ""
|
||||
@@ -581,6 +660,7 @@ async def show_faq_page(
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -589,6 +669,18 @@ async def show_privacy_policy(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
@@ -687,6 +779,7 @@ async def show_privacy_policy(
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -696,6 +789,18 @@ async def show_public_offer(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
@@ -794,6 +899,7 @@ async def show_public_offer(
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -803,6 +909,18 @@ async def show_language_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
@@ -833,6 +951,18 @@ async def process_language_change(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
@@ -888,6 +1018,18 @@ async def handle_back_to_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"USER_NOT_FOUND_ERROR",
|
||||
"Ошибка: пользователь не найден.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -902,7 +1044,7 @@ async def handle_back_to_menu(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
|
||||
# Проверяем наличие сохраненной корзины в Redis
|
||||
try:
|
||||
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
|
||||
@@ -924,22 +1066,26 @@ async def handle_back_to_menu(
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = await get_main_menu_keyboard_async(
|
||||
db=db,
|
||||
user=db_user,
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
force_text=settings.is_text_main_menu_mode(),
|
||||
)
|
||||
@@ -952,7 +1098,8 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
actual_status = (subscription.actual_status or "").lower()
|
||||
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
|
||||
days_left = 0
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
@@ -968,10 +1115,16 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date_text)
|
||||
).format(end_date=end_date_text or "—")
|
||||
|
||||
if actual_status == "trial":
|
||||
if days_left > 1:
|
||||
is_trial_subscription = getattr(subscription, "is_trial", False)
|
||||
|
||||
is_trial_like_status = actual_status == "trial" or (
|
||||
is_trial_subscription and actual_status in {"active", "trial"}
|
||||
)
|
||||
|
||||
if is_trial_like_status:
|
||||
if days_left > 1 and end_date_text:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
@@ -990,7 +1143,7 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
)
|
||||
|
||||
if actual_status == "active":
|
||||
if days_left > 7:
|
||||
if days_left > 7 and end_date_text:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
@@ -1072,15 +1225,94 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
return _insert_random_message(base_text, random_message, action_prompt)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
|
||||
return base_text
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
async def handle_activate_button(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Получить подписку пользователя
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
|
||||
await callback.answer(
|
||||
texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Параметры из подписки или дефолтные
|
||||
device_limit = subscription.device_limit if subscription else settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit_gb = subscription.traffic_limit_gb if subscription else 0
|
||||
connected_squads = subscription.connected_squads if subscription else []
|
||||
|
||||
# Получить IDs серверов из UUIDs
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
|
||||
balance = db_user.balance_kopeks
|
||||
available_periods = [int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS]
|
||||
|
||||
best_period = None
|
||||
best_price = 0
|
||||
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# Найти максимальный период, цена которого <= баланса
|
||||
for period in sorted(available_periods, reverse=True):
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period,
|
||||
traffic_limit_gb,
|
||||
server_ids,
|
||||
device_limit,
|
||||
db,
|
||||
user=db_user
|
||||
)
|
||||
if price <= balance:
|
||||
best_period = period
|
||||
best_price = price
|
||||
break
|
||||
|
||||
if best_period:
|
||||
# Создать новую подписку
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
new_subscription = await create_paid_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
best_period,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=True
|
||||
)
|
||||
|
||||
# Списать деньги
|
||||
db_user.balance_kopeks -= best_price
|
||||
await db.commit()
|
||||
|
||||
await callback.answer(
|
||||
texts.t("ACTIVATION_SUCCESS", f"✅ Подписка активирована на {best_period} дней за {best_price//100} руб!"),
|
||||
show_alert=True,
|
||||
)
|
||||
else:
|
||||
await callback.answer(
|
||||
texts.t("INSUFFICIENT_FUNDS", "❌ Недостаточно средств для активации подписки"),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_back_to_menu,
|
||||
F.data == "back_to_menu"
|
||||
@@ -1146,3 +1378,13 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(None)
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_add_traffic,
|
||||
F.data == "buy_traffic"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_activate_button,
|
||||
F.data == "activate_button"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.poll import (
|
||||
get_poll_response_by_id,
|
||||
record_poll_answer,
|
||||
)
|
||||
from app.database.models import PollQuestion, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
await bot.delete_message(chat_id, message_id)
|
||||
except Exception as error: # pragma: no cover - cleanup best effort
|
||||
logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error)
|
||||
|
||||
|
||||
async def _render_question_text(
|
||||
poll_title: str,
|
||||
question: PollQuestion,
|
||||
current_index: int,
|
||||
total: int,
|
||||
language: str,
|
||||
) -> str:
|
||||
texts = get_texts(language)
|
||||
header = texts.t("POLL_QUESTION_HEADER", "<b>Вопрос {current}/{total}</b>").format(
|
||||
current=current_index,
|
||||
total=total,
|
||||
)
|
||||
lines = [f"🗳️ <b>{poll_title}</b>", "", header, "", question.text]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _update_poll_message(
|
||||
message: types.Message,
|
||||
text: str,
|
||||
*,
|
||||
reply_markup: types.InlineKeyboardMarkup | None = None,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> bool:
|
||||
try:
|
||||
await message.edit_text(
|
||||
text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
return True
|
||||
except TelegramBadRequest as error:
|
||||
error_text = str(error).lower()
|
||||
if "message is not modified" in error_text:
|
||||
logger.debug(
|
||||
"Опросное сообщение уже актуально, пропускаем обновление: %s",
|
||||
error,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
"Не удалось обновить сообщение опроса %s: %s",
|
||||
message.message_id,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.exception(
|
||||
"Непредвиденная ошибка при обновлении сообщения опроса %s: %s",
|
||||
message.message_id,
|
||||
error,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for option in sorted(question.options, key=lambda o: o.order):
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=option.text,
|
||||
callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
async def handle_poll_start(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
response_id = int(callback.data.split(":")[1])
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
if not response or response.user_id != db_user.id:
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if response.completed_at:
|
||||
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
|
||||
return
|
||||
|
||||
if not response.poll or not response.poll.questions:
|
||||
await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True)
|
||||
return
|
||||
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
index, question = await get_next_question(response)
|
||||
if not question:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True)
|
||||
return
|
||||
|
||||
question_text = await _render_question_text(
|
||||
response.poll.title,
|
||||
question,
|
||||
index,
|
||||
len(response.poll.questions),
|
||||
db_user.language,
|
||||
)
|
||||
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
question_text,
|
||||
reply_markup=_build_options_keyboard(response.id, question),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_poll_answer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
_, response_id, question_id, option_id = callback.data.split(":", 3)
|
||||
response_id = int(response_id)
|
||||
question_id = int(question_id)
|
||||
option_id = int(option_id)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not response or response.user_id != db_user.id:
|
||||
await callback.answer("❌ Опрос не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if not response.poll:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
|
||||
return
|
||||
|
||||
if response.completed_at:
|
||||
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
|
||||
return
|
||||
|
||||
question = next((q for q in response.poll.questions if q.id == question_id), None)
|
||||
if not question:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
option = await get_question_option(question, option_id)
|
||||
if not option:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
await record_poll_answer(
|
||||
db,
|
||||
response_id=response.id,
|
||||
question_id=question.id,
|
||||
option_id=option.id,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(response, attribute_names=["answers"])
|
||||
except Exception as error: # pragma: no cover - defensive cache busting
|
||||
logger.debug(
|
||||
"Не удалось обновить локальные ответы опроса %s: %s",
|
||||
response.id,
|
||||
error,
|
||||
)
|
||||
response = await get_poll_response_by_id(db, response.id)
|
||||
if not response:
|
||||
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
|
||||
return
|
||||
index, next_question = await get_next_question(response)
|
||||
|
||||
if next_question:
|
||||
question_text = await _render_question_text(
|
||||
response.poll.title,
|
||||
next_question,
|
||||
index,
|
||||
len(response.poll.questions),
|
||||
db_user.language,
|
||||
)
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
question_text,
|
||||
reply_markup=_build_options_keyboard(response.id, next_question),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
|
||||
return
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
response.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
reward_amount = await reward_user_for_poll(db, response)
|
||||
|
||||
thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")]
|
||||
if reward_amount:
|
||||
thanks_lines.append(
|
||||
texts.t(
|
||||
"POLL_REWARD_GRANTED",
|
||||
"Награда {amount} зачислена на ваш баланс.",
|
||||
).format(amount=settings.format_price(reward_amount))
|
||||
)
|
||||
|
||||
if not await _update_poll_message(
|
||||
callback.message,
|
||||
"\n\n".join(thanks_lines),
|
||||
):
|
||||
await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!"))
|
||||
return
|
||||
asyncio.create_task(
|
||||
_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:"))
|
||||
dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:"))
|
||||
+68
-26
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import PromoCodeStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -32,6 +32,47 @@ async def show_promocode_menu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def activate_promocode_for_registration(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
code: str,
|
||||
bot: Bot = None
|
||||
) -> dict:
|
||||
"""
|
||||
Активирует промокод для пользователя во время регистрации.
|
||||
Возвращает результат активации без отправки сообщений.
|
||||
"""
|
||||
promocode_service = PromoCodeService()
|
||||
result = await promocode_service.activate_promocode(db, user_id, code)
|
||||
|
||||
if result["success"]:
|
||||
logger.info(f"✅ Пользователь {user_id} активировал промокод {code} при регистрации")
|
||||
|
||||
# Отправляем уведомление админу, если бот доступен
|
||||
if bot:
|
||||
try:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
result.get("balance_before_kopeks"),
|
||||
result.get("balance_after_kopeks"),
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления об активации промокода %s: %s",
|
||||
code,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_promocode(
|
||||
message: types.Message,
|
||||
@@ -39,10 +80,28 @@ async def process_promocode(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id,
|
||||
message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
|
||||
try:
|
||||
await message.answer(
|
||||
f"🚫 Активация промокода невозможна\n\n"
|
||||
f"Причина: {blacklist_reason}\n\n"
|
||||
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
|
||||
if not code:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
@@ -52,31 +111,14 @@ async def process_promocode(
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
promocode_service = PromoCodeService()
|
||||
result = await promocode_service.activate_promocode(db, db_user.id, code)
|
||||
|
||||
|
||||
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
|
||||
|
||||
if result["success"]:
|
||||
await message.answer(
|
||||
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
db_user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления об активации промокода %s: %s",
|
||||
code,
|
||||
notify_error,
|
||||
)
|
||||
else:
|
||||
error_messages = {
|
||||
"not_found": texts.PROMOCODE_INVALID,
|
||||
@@ -85,13 +127,13 @@ async def process_promocode(
|
||||
"already_used_by_user": texts.PROMOCODE_USED,
|
||||
"server_error": texts.ERROR
|
||||
}
|
||||
|
||||
|
||||
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
|
||||
await message.answer(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.localization.texts import get_texts
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.utils.user_utils import (
|
||||
get_detailed_referral_list,
|
||||
get_effective_referral_commission_percent,
|
||||
get_referral_analytics,
|
||||
get_user_referral_summary,
|
||||
)
|
||||
@@ -86,7 +87,7 @@ async def show_referral_info(
|
||||
+ texts.t(
|
||||
"REFERRAL_REWARD_COMMISSION",
|
||||
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
||||
).format(percent=settings.REFERRAL_COMMISSION_PERCENT)
|
||||
).format(percent=get_effective_referral_commission_percent(db_user))
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
|
||||
+ f"\n<code>{referral_link}</code>\n\n"
|
||||
|
||||
+1748
-172
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
@@ -18,9 +18,11 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
logger.info(
|
||||
f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}"
|
||||
)
|
||||
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_")
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
@@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
if not user:
|
||||
@@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
async def handle_successful_payment(
|
||||
message: types.Message,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
**kwargs
|
||||
):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
@@ -106,6 +110,27 @@ async def handle_successful_payment(
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
state_data = await state.get_data()
|
||||
prompt_message_id = state_data.get("stars_prompt_message_id")
|
||||
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
|
||||
invoice_message_id = state_data.get("stars_invoice_message_id")
|
||||
invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id)
|
||||
|
||||
for chat_id, message_id, label in [
|
||||
(prompt_chat_id, prompt_message_id, "запрос суммы"),
|
||||
(invoice_chat_id, invoice_message_id, "инвойс Stars"),
|
||||
]:
|
||||
if message_id:
|
||||
try:
|
||||
await message.bot.delete_message(chat_id, message_id)
|
||||
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||
logger.warning(
|
||||
"Не удалось удалить сообщение %s после оплаты Stars: %s",
|
||||
label,
|
||||
delete_error,
|
||||
)
|
||||
|
||||
success = await payment_service.process_stars_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -113,7 +138,14 @@ async def handle_successful_payment(
|
||||
payload=payment.invoice_payload,
|
||||
telegram_payment_charge_id=payment.telegram_payment_charge_id
|
||||
)
|
||||
|
||||
|
||||
await state.update_data(
|
||||
stars_prompt_message_id=None,
|
||||
stars_prompt_chat_id=None,
|
||||
stars_invoice_message_id=None,
|
||||
stars_invoice_chat_id=None,
|
||||
)
|
||||
|
||||
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))
|
||||
@@ -130,6 +162,10 @@ async def handle_successful_payment(
|
||||
"⭐ Потрачено звезд: {stars_spent}\n"
|
||||
"💰 Зачислено на баланс: {amount} ₽\n"
|
||||
"🆔 ID транзакции: {transaction_id}...\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
"Спасибо за пополнение! 🚀",
|
||||
).format(
|
||||
stars_spent=payment.total_amount,
|
||||
@@ -168,15 +204,15 @@ async def handle_successful_payment(
|
||||
|
||||
|
||||
def register_stars_handlers(dp: Dispatcher):
|
||||
|
||||
|
||||
dp.pre_checkout_query.register(
|
||||
handle_pre_checkout_query,
|
||||
F.currency == "XTR"
|
||||
F.currency == "XTR"
|
||||
)
|
||||
|
||||
|
||||
dp.message.register(
|
||||
handle_successful_payment,
|
||||
F.successful_payment
|
||||
)
|
||||
|
||||
|
||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+641
-278
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,7 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.states import SubscriptionStates
|
||||
@@ -186,7 +187,7 @@ async def handle_subscription_config_back(
|
||||
if current_state == SubscriptionStates.selecting_traffic.state:
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
@@ -201,35 +202,26 @@ async def handle_subscription_config_back(
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_devices.state:
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
elif current_state == SubscriptionStates.confirming_purchase.state:
|
||||
if settings.is_devices_selection_enabled():
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
texts.SELECT_DEVICES,
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
elif settings.is_traffic_selectable():
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_TRAFFIC,
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
await _show_previous_configuration_step(callback, state, db_user, texts, db)
|
||||
|
||||
else:
|
||||
from app.handlers.menu import show_main_menu
|
||||
@@ -249,7 +241,44 @@ async def handle_subscription_cancel(
|
||||
await state.clear()
|
||||
await clear_subscription_checkout_draft(db_user.id)
|
||||
|
||||
# Удаляем сохраненную корзину, чтобы не показывать кнопку возврата
|
||||
await user_cart_service.delete_user_cart(db_user.id)
|
||||
|
||||
from app.handlers.menu import show_main_menu
|
||||
await show_main_menu(callback, db_user, db)
|
||||
|
||||
await callback.answer("❌ Покупка отменена")
|
||||
async def _show_previous_configuration_step(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
texts,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
return
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_TRAFFIC,
|
||||
reply_markup=get_traffic_packages_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ def _get_addon_discount_percent_for_user(
|
||||
if user is None:
|
||||
return 0
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group = user.get_primary_promo_group()
|
||||
if promo_group is None:
|
||||
return 0
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ from app.utils.promo_offer import (
|
||||
)
|
||||
|
||||
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def handle_add_countries(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -405,6 +406,17 @@ async def apply_countries_changes(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, что пользователь не пытается отключить все страны (должна остаться хотя бы 1 страна)
|
||||
if len(selected_countries) == 0:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"COUNTRIES_MINIMUM_REQUIRED",
|
||||
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if added and total_cost > 0:
|
||||
success = await subtract_user_balance(
|
||||
@@ -588,6 +600,11 @@ async def countries_continue(
|
||||
await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
|
||||
return
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -892,6 +909,17 @@ async def confirm_add_countries_to_subscription(
|
||||
return
|
||||
|
||||
try:
|
||||
# Проверяем, что пользователь не пытается отключить все страны (должна остаться хотя бы 1 страна)
|
||||
if len(selected_countries) == 0:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"COUNTRIES_MINIMUM_REQUIRED",
|
||||
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
if new_countries and total_price > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, total_price,
|
||||
|
||||
@@ -183,6 +183,13 @@ async def handle_change_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer(
|
||||
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
|
||||
@@ -233,6 +240,13 @@ async def confirm_change_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
if new_devices_count == current_devices:
|
||||
@@ -379,6 +393,13 @@ async def execute_change_devices(
|
||||
subscription = db_user.subscription
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(
|
||||
@@ -863,6 +884,13 @@ async def confirm_add_devices(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
resume_callback = None
|
||||
|
||||
new_total_devices = subscription.device_limit + devices_count
|
||||
|
||||
@@ -77,6 +77,7 @@ from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
|
||||
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
|
||||
@@ -158,7 +159,18 @@ async def _prepare_subscription_summary(
|
||||
total_servers_discount += total_discount_for_server
|
||||
selected_server_prices.append(total_price_for_server)
|
||||
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
forced_disabled_limit: Optional[int] = None
|
||||
if devices_selection_enabled:
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
else:
|
||||
forced_disabled_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_disabled_limit is None:
|
||||
devices_selected = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
devices_selected = forced_disabled_limit
|
||||
|
||||
summary_data['devices'] = devices_selected
|
||||
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
@@ -241,17 +253,20 @@ async def _prepare_subscription_summary(
|
||||
else:
|
||||
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
|
||||
|
||||
if base_discount_total > 0:
|
||||
details_lines = []
|
||||
|
||||
# Добавляем строку базового периода только если цена не равна 0
|
||||
if base_discount_total > 0 and base_price > 0:
|
||||
base_line = (
|
||||
f"- Базовый период: <s>{texts.format_price(base_price_original)}</s> "
|
||||
f"{texts.format_price(base_price)}"
|
||||
f" (скидка {period_discount_percent}%:"
|
||||
f" -{texts.format_price(base_discount_total)})"
|
||||
)
|
||||
else:
|
||||
details_lines.append(base_line)
|
||||
elif base_price_original > 0:
|
||||
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
|
||||
|
||||
details_lines = [base_line]
|
||||
details_lines.append(base_line)
|
||||
|
||||
if total_traffic_price > 0:
|
||||
traffic_line = (
|
||||
@@ -275,7 +290,7 @@ async def _prepare_subscription_summary(
|
||||
f" -{texts.format_price(total_servers_discount)})"
|
||||
)
|
||||
details_lines.append(servers_line)
|
||||
if total_devices_price > 0:
|
||||
if devices_selection_enabled and total_devices_price > 0:
|
||||
devices_line = (
|
||||
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_devices_price)}"
|
||||
@@ -300,17 +315,28 @@ async def _prepare_subscription_summary(
|
||||
|
||||
details_text = "\n".join(details_lines)
|
||||
|
||||
summary_text = (
|
||||
"📋 <b>Сводка заказа</b>\n\n"
|
||||
f"📅 <b>Период:</b> {period_display}\n"
|
||||
f"📊 <b>Трафик:</b> {traffic_display}\n"
|
||||
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
|
||||
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
|
||||
"💰 <b>Детализация стоимости:</b>\n"
|
||||
f"{details_text}\n\n"
|
||||
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
|
||||
"Подтверждаете покупку?"
|
||||
)
|
||||
summary_lines = [
|
||||
"📋 <b>Сводка заказа</b>",
|
||||
"",
|
||||
f"📅 <b>Период:</b> {period_display}",
|
||||
f"📊 <b>Трафик:</b> {traffic_display}",
|
||||
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}",
|
||||
]
|
||||
|
||||
if devices_selection_enabled:
|
||||
summary_lines.append(f"📱 <b>Устройства:</b> {devices_selected}")
|
||||
|
||||
summary_lines.extend([
|
||||
"",
|
||||
"💰 <b>Детализация стоимости:</b>",
|
||||
details_text,
|
||||
"",
|
||||
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}",
|
||||
"",
|
||||
"Подтверждаете покупку?",
|
||||
])
|
||||
|
||||
summary_text = "\n".join(summary_lines)
|
||||
|
||||
return summary_text, summary_data
|
||||
|
||||
@@ -327,7 +353,7 @@ async def _build_subscription_period_prompt(
|
||||
if promo_offer_hint:
|
||||
lines.extend(["", promo_offer_hint])
|
||||
|
||||
promo_text = _build_promo_group_discount_text(
|
||||
promo_text = await _build_promo_group_discount_text(
|
||||
db_user,
|
||||
settings.get_available_subscription_periods(),
|
||||
texts=texts,
|
||||
@@ -382,7 +408,18 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
)
|
||||
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
@@ -410,7 +447,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
return 0
|
||||
|
||||
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||||
|
||||
if devices_selection_enabled:
|
||||
devices_used = await get_current_devices_count(db_user)
|
||||
else:
|
||||
devices_used = 0
|
||||
countries_info = await _get_countries_info(subscription.connected_squads)
|
||||
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
|
||||
|
||||
@@ -439,10 +481,21 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
|
||||
subscription_cost = await get_subscription_cost(subscription, db)
|
||||
|
||||
info_text = texts.SUBSCRIPTION_INFO.format(
|
||||
info_template = texts.SUBSCRIPTION_INFO
|
||||
|
||||
if not devices_selection_enabled:
|
||||
info_template = info_template.replace(
|
||||
"\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}",
|
||||
"",
|
||||
).replace(
|
||||
"\n📱 <b>Devices:</b> {devices_used} / {devices_limit}",
|
||||
"",
|
||||
)
|
||||
|
||||
info_text = info_template.format(
|
||||
status=status_text,
|
||||
type=type_text,
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
|
||||
days_left=max(0, subscription.days_left),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
|
||||
traffic_limit=traffic_text,
|
||||
|
||||
@@ -88,12 +88,12 @@ async def _get_promo_offer_hint(
|
||||
) -> Optional[str]:
|
||||
return await build_promo_offer_hint(db, db_user, texts, percent)
|
||||
|
||||
def _build_promo_group_discount_text(
|
||||
async def _build_promo_group_discount_text(
|
||||
db_user: User,
|
||||
periods: Optional[List[int]] = None,
|
||||
texts=None,
|
||||
) -> str:
|
||||
promo_group = getattr(db_user, "promo_group", None)
|
||||
promo_group = db_user.get_primary_promo_group()
|
||||
|
||||
if not promo_group:
|
||||
return ""
|
||||
|
||||
+1366
-362
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_checkout_service import save_subscription_checkout_draft
|
||||
from app.states import SubscriptionStates
|
||||
from app.keyboards.inline import get_subscription_confirm_keyboard
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - only for type checking
|
||||
from .pricing import _prepare_subscription_summary
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def present_subscription_summary(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
texts: Optional = None,
|
||||
) -> bool:
|
||||
"""Render the subscription purchase summary and switch to the confirmation state.
|
||||
|
||||
Returns ``True`` when the summary is shown successfully and ``False`` if
|
||||
calculation failed (an error is shown to the user in this case).
|
||||
"""
|
||||
|
||||
if texts is None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
|
||||
from .pricing import _prepare_subscription_summary
|
||||
|
||||
try:
|
||||
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Ошибка в расчете цены подписки для пользователя %s: %s",
|
||||
db_user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
|
||||
return False
|
||||
|
||||
await state.set_data(prepared_data)
|
||||
await save_subscription_checkout_draft(db_user.id, prepared_data)
|
||||
|
||||
await callback.message.edit_text(
|
||||
summary_text,
|
||||
reply_markup=get_subscription_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(SubscriptionStates.confirming_purchase)
|
||||
return True
|
||||
@@ -80,6 +80,7 @@ from app.utils.promo_offer import (
|
||||
|
||||
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
|
||||
from .countries import _get_available_countries, _should_show_countries_management
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def handle_add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -352,12 +353,15 @@ async def select_traffic(
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
else:
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
|
||||
await state.set_data(data)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
available_countries = [c for c in countries if c.get('is_available', True)]
|
||||
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
|
||||
await state.set_data(data)
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -365,8 +369,11 @@ async def select_traffic(
|
||||
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_devices)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
if await present_subscription_summary(callback, state, db_user, texts):
|
||||
await callback.answer()
|
||||
|
||||
async def add_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
|
||||
@@ -114,7 +114,11 @@ async def handle_successful_payment(message: types.Message):
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса."
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
|
||||
|
||||
+256
-3
@@ -53,6 +53,16 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="admin_submenu_system",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"),
|
||||
callback_data="admin_trials",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
|
||||
callback_data="admin_payments",
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
@@ -85,6 +95,12 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS", "🏆 Конкурсы"),
|
||||
callback_data="admin_contests",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups")
|
||||
],
|
||||
@@ -101,6 +117,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"),
|
||||
callback_data="admin_polls",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
|
||||
@@ -229,6 +251,20 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar
|
||||
])
|
||||
|
||||
|
||||
def get_admin_trials_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_TRIALS_RESET_BUTTON", "♻️ Сбросить все триалы"),
|
||||
callback_data="admin_trials_reset",
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")],
|
||||
])
|
||||
|
||||
|
||||
def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -290,6 +326,18 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="admin_users_filters"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USERS_BLACKLIST", "🔐 Черный список"),
|
||||
callback_data="admin_blacklist_settings"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USERS_BULK_BAN", "🛑 Массовый бан"),
|
||||
callback_data="admin_bulk_ban_start"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_users")
|
||||
]
|
||||
@@ -330,6 +378,12 @@ def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
callback_data="admin_users_purchases_filter"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USERS_FILTER_RENEW_READY", "♻️ Готовы к продлению"),
|
||||
callback_data="admin_users_ready_to_renew_filter"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USERS_FILTER_CAMPAIGN", "📢 По кампании"),
|
||||
@@ -423,6 +477,181 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_admin_contests_root_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_REFERRAL", "🤝 Реферальные конкурсы"),
|
||||
callback_data="admin_contests_referral",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_DAILY", "📆 Ежедневные конкурсы"),
|
||||
callback_data="admin_contests_daily",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo"),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_admin_contests_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_LIST", "📋 Текущие конкурсы"),
|
||||
callback_data="admin_contests_list",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_CREATE", "➕ Новый конкурс"),
|
||||
callback_data="admin_contests_create",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_contests",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_contest_mode_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_MODE_PAID", "💳 Реферал с покупкой"),
|
||||
callback_data="admin_contest_mode_paid",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_MODE_REGISTERED", "🧑🤝🧑 Просто реферал"),
|
||||
callback_data="admin_contest_mode_registered",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_referral")
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_daily_contest_manage_keyboard(
|
||||
template_id: int,
|
||||
is_enabled: bool,
|
||||
language: str = "ru",
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
toggle_text = _t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить") if is_enabled else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить")
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=toggle_text, callback_data=f"admin_daily_toggle_{template_id}"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_NOW", "🚀 Запустить раунд"), callback_data=f"admin_daily_start_{template_id}"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_MANUAL", "🧪 Ручной старт"), callback_data=f"admin_daily_manual_{template_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE", "🏅 Приз (дни)"), callback_data=f"admin_daily_edit_{template_id}_prize_days"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_ATTEMPTS", "🔁 Попытки"), callback_data=f"admin_daily_edit_{template_id}_attempts_per_user"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_TIMES", "⏰ Раундов/день"), callback_data=f"admin_daily_edit_{template_id}_times_per_day"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_SCHEDULE", "🕒 Расписание"), callback_data=f"admin_daily_edit_{template_id}_schedule_times"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_COOLDOWN", "⌛ Длительность"), callback_data=f"admin_daily_edit_{template_id}_cooldown_hours"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PAYLOAD", "🧩 Payload"), callback_data=f"admin_daily_payload_{template_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_RESET_ATTEMPTS", "🔄 Сбросить попытки"), callback_data=f"admin_daily_reset_attempts_{template_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_CLOSE_ROUND", "❌ Закрыть раунд"), callback_data=f"admin_daily_close_{template_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_daily"),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
def get_referral_contest_manage_keyboard(
|
||||
contest_id: int,
|
||||
*,
|
||||
is_active: bool,
|
||||
can_delete: bool = False,
|
||||
language: str = "ru",
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
toggle_text = (
|
||||
_t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить")
|
||||
if is_active
|
||||
else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить")
|
||||
)
|
||||
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_LEADERBOARD", "📊 Лидеры"),
|
||||
callback_data=f"admin_contest_leaderboard_{contest_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data=f"admin_contest_toggle_{contest_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📈 Детальная статистика",
|
||||
callback_data=f"admin_contest_detailed_stats_{contest_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_EDIT_SUMMARY_TIMES", "🕒 Итоги в день"),
|
||||
callback_data=f"admin_contest_edit_times_{contest_id}",
|
||||
),
|
||||
],
|
||||
]
|
||||
|
||||
if can_delete:
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_DELETE", "🗑 Удалить"),
|
||||
callback_data=f"admin_contest_delete_{contest_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"),
|
||||
callback_data="admin_contests_list",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def get_campaign_management_keyboard(
|
||||
campaign_id: int, is_active: bool, language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
@@ -748,6 +977,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
callback_data=f"admin_user_promo_group_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"),
|
||||
callback_data=f"admin_user_referrals_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
|
||||
@@ -809,20 +1044,28 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
def get_user_promo_group_keyboard(
|
||||
promo_groups: List[Tuple[Any, int]],
|
||||
user_id: int,
|
||||
current_group_id: Optional[int],
|
||||
current_group_ids, # Can be Optional[int] or List[int]
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
# Ensure current_group_ids is a list
|
||||
if current_group_ids is None:
|
||||
current_group_ids = []
|
||||
elif isinstance(current_group_ids, int):
|
||||
current_group_ids = [current_group_ids]
|
||||
|
||||
keyboard: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
for group, members_count in promo_groups:
|
||||
prefix = "✅" if current_group_id is not None and group.id == current_group_id else "👥"
|
||||
# Check if user has this group
|
||||
has_group = group.id in current_group_ids
|
||||
prefix = "✅" if has_group else "👥"
|
||||
count_text = f" ({members_count})" if members_count else ""
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{prefix} {group.name}{count_text}",
|
||||
callback_data=f"admin_user_promo_group_set_{user_id}_{group.id}"
|
||||
callback_data=f"admin_user_promo_group_toggle_{user_id}_{group.id}"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -869,6 +1112,10 @@ def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PROMOCODE_TYPE_TRIAL", "🎁 Триал"),
|
||||
callback_data="promo_type_trial"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PROMOCODE_TYPE_PROMO_GROUP", "🏷️ Промогруппа"),
|
||||
callback_data="promo_type_group"
|
||||
)
|
||||
],
|
||||
[
|
||||
@@ -1070,6 +1317,12 @@ def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="sync_all_users"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SYNC_TO_PANEL", "⬆️ Синхронизация в панель"),
|
||||
callback_data="sync_to_panel"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SYNC_ONLY_NEW", "🆕 Только новые"),
|
||||
|
||||
+415
-135
@@ -1,7 +1,7 @@
|
||||
from typing import List, Optional
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from app.database.models import User
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -9,7 +9,11 @@ from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.utils.pricing_utils import format_period_description, apply_percentage_discount
|
||||
from app.utils.pricing_utils import (
|
||||
format_period_description,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
from app.utils.price_display import PriceInfo, format_price_button
|
||||
from app.utils.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
@@ -19,6 +23,136 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_main_menu_keyboard_async(
|
||||
db: AsyncSession,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
is_admin: bool = False,
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
subscription_is_active: bool = False,
|
||||
balance_kopeks: int = 0,
|
||||
subscription=None,
|
||||
show_resume_checkout: bool = False,
|
||||
has_saved_cart: bool = False,
|
||||
*,
|
||||
is_moderator: bool = False,
|
||||
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
|
||||
user=None, # Добавляем параметр пользователя для получения данных
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Асинхронная версия get_main_menu_keyboard с поддержкой конструктора меню.
|
||||
|
||||
Если MENU_LAYOUT_ENABLED=True, использует конфигурацию из БД.
|
||||
Иначе делегирует в синхронную версию.
|
||||
"""
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
from app.services.menu_layout_service import MenuLayoutService, MenuContext
|
||||
from datetime import datetime
|
||||
|
||||
# Получаем данные для плейсхолдеров
|
||||
subscription_days_left = 0
|
||||
traffic_used_gb = 0.0
|
||||
traffic_left_gb = 0.0
|
||||
referral_count = 0
|
||||
referral_earnings_kopeks = 0
|
||||
registration_days = 0
|
||||
promo_group_id = None
|
||||
has_autopay = False
|
||||
username = ""
|
||||
|
||||
# Заполняем данными из подписки
|
||||
if subscription:
|
||||
# Дни до окончания подписки
|
||||
if hasattr(subscription, 'days_left'):
|
||||
# Используем свойство из модели, которое правильно вычисляет дни в UTC
|
||||
subscription_days_left = subscription.days_left
|
||||
elif hasattr(subscription, 'end_date') and subscription.end_date:
|
||||
# Fallback: вычисляем вручную, используя UTC
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
days_left = (subscription.end_date - now_utc).days
|
||||
subscription_days_left = max(0, days_left)
|
||||
|
||||
# Трафик
|
||||
if hasattr(subscription, 'traffic_used_gb'):
|
||||
traffic_used_gb = subscription.traffic_used_gb or 0.0
|
||||
|
||||
if hasattr(subscription, 'traffic_limit_gb') and subscription.traffic_limit_gb:
|
||||
traffic_left_gb = max(0, subscription.traffic_limit_gb - (subscription.traffic_used_gb or 0))
|
||||
|
||||
# Автоплатеж
|
||||
if hasattr(subscription, 'autopay_enabled'):
|
||||
has_autopay = subscription.autopay_enabled
|
||||
|
||||
# Получаем данные пользователя
|
||||
if user:
|
||||
# Имя пользователя
|
||||
if hasattr(user, 'username') and user.username:
|
||||
username = user.username
|
||||
elif hasattr(user, 'first_name') and user.first_name:
|
||||
username = user.first_name
|
||||
|
||||
# Дни с регистрации
|
||||
if hasattr(user, 'created_at') and user.created_at:
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
registration_days = (now_utc - user.created_at).days
|
||||
|
||||
# ID промо-группы
|
||||
if hasattr(user, 'promo_group_id'):
|
||||
promo_group_id = user.promo_group_id
|
||||
|
||||
# Получаем данные о рефералах из БД (если нужно)
|
||||
try:
|
||||
from app.database.crud.referral import get_user_referral_stats
|
||||
if user and hasattr(user, 'id'):
|
||||
referral_data = await get_user_referral_stats(db, user.id)
|
||||
if referral_data:
|
||||
referral_count = referral_data.get('invited_count', 0)
|
||||
referral_earnings_kopeks = referral_data.get('total_earned_kopeks', 0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting referral data: {e}")
|
||||
|
||||
context = MenuContext(
|
||||
language=language,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
balance_kopeks=balance_kopeks,
|
||||
subscription=subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
custom_buttons=custom_buttons or [],
|
||||
# Добавляем данные для плейсхолдеров
|
||||
username=username,
|
||||
subscription_days=subscription_days_left,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
traffic_left_gb=traffic_left_gb,
|
||||
referral_count=referral_count,
|
||||
referral_earnings_kopeks=referral_earnings_kopeks,
|
||||
registration_days=registration_days,
|
||||
promo_group_id=promo_group_id,
|
||||
has_autopay=has_autopay,
|
||||
)
|
||||
|
||||
return await MenuLayoutService.build_keyboard(db, context)
|
||||
|
||||
# Fallback на синхронную версию
|
||||
return get_main_menu_keyboard(
|
||||
language=language,
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks,
|
||||
subscription=subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
has_saved_cart=has_saved_cart,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
|
||||
def _get_localized_value(values, language: str, default_language: str = "en") -> str:
|
||||
if not isinstance(values, dict):
|
||||
return ""
|
||||
@@ -78,7 +212,40 @@ def _build_additional_buttons(additional_section, language: str) -> List[InlineK
|
||||
|
||||
_LANGUAGE_DISPLAY_NAMES = {
|
||||
"ru": "🇷🇺 Русский",
|
||||
"ru-ru": "🇷🇺 Русский",
|
||||
"en": "🇬🇧 English",
|
||||
"en-us": "🇺🇸 English",
|
||||
"en-gb": "🇬🇧 English",
|
||||
"ua": "🇺🇦 Українська",
|
||||
"uk": "🇺🇦 Українська",
|
||||
"uk-ua": "🇺🇦 Українська",
|
||||
"kk": "🇰🇿 Қазақша",
|
||||
"kk-kz": "🇰🇿 Қазақша",
|
||||
"kz": "🇰🇿 Қазақша",
|
||||
"uz": "🇺🇿 Oʻzbekcha",
|
||||
"uz-uz": "🇺🇿 Oʻzbekcha",
|
||||
"tr": "🇹🇷 Türkçe",
|
||||
"tr-tr": "🇹🇷 Türkçe",
|
||||
"pl": "🇵🇱 Polski",
|
||||
"pl-pl": "🇵🇱 Polski",
|
||||
"de": "🇩🇪 Deutsch",
|
||||
"de-de": "🇩🇪 Deutsch",
|
||||
"fr": "🇫🇷 Français",
|
||||
"fr-fr": "🇫🇷 Français",
|
||||
"es": "🇪🇸 Español",
|
||||
"es-es": "🇪🇸 Español",
|
||||
"it": "🇮🇹 Italiano",
|
||||
"it-it": "🇮🇹 Italiano",
|
||||
"pt": "🇵🇹 Português",
|
||||
"pt-pt": "🇵🇹 Português",
|
||||
"pt-br": "🇧🇷 Português",
|
||||
"zh": "🇨🇳 中文",
|
||||
"zh-cn": "🇨🇳 中文 (简体)",
|
||||
"zh-hans": "🇨🇳 中文 (简体)",
|
||||
"zh-tw": "🇹🇼 中文 (繁體)",
|
||||
"zh-hant": "🇹🇼 中文 (繁體)",
|
||||
"vi": "🇻🇳 Tiếng Việt",
|
||||
"vi-vn": "🇻🇳 Tiếng Việt",
|
||||
}
|
||||
|
||||
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
@@ -90,28 +257,50 @@ def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup
|
||||
]
|
||||
])
|
||||
|
||||
def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.PRIVACY_POLICY_ACCEPT,
|
||||
callback_data="privacy_policy_accept"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=texts.PRIVACY_POLICY_DECLINE,
|
||||
callback_data="privacy_policy_decline"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
def get_channel_sub_keyboard(
|
||||
channel_link: str,
|
||||
channel_link: Optional[str],
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
|
||||
buttons: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
if channel_link:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
||||
url=channel_link,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
|
||||
callback_data="sub_channel_check",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
|
||||
callback_data="sub_channel_check",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
@@ -257,7 +446,8 @@ def get_main_menu_keyboard(
|
||||
"💰 Баланс: {balance}",
|
||||
).format(balance=texts.format_price(balance_kopeks))
|
||||
|
||||
keyboard = []
|
||||
keyboard: list[list[InlineKeyboardButton]] = []
|
||||
paired_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
@@ -312,22 +502,36 @@ def get_main_menu_keyboard(
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
keyboard.append(happ_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
])
|
||||
)
|
||||
|
||||
# Добавляем кнопку докупки трафика для лимитированных подписок
|
||||
if subscription and not subscription.is_trial and subscription.traffic_limit_gb > 0:
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic")
|
||||
)
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
|
||||
show_buy = not has_active_subscription or not subscription_is_active
|
||||
|
||||
subscription_buttons = []
|
||||
|
||||
current_subscription = subscription
|
||||
has_active_paid_subscription = bool(
|
||||
current_subscription
|
||||
and not getattr(current_subscription, "is_trial", False)
|
||||
and getattr(current_subscription, "is_active", False)
|
||||
)
|
||||
simple_purchase_button = None
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
simple_purchase_button = InlineKeyboardButton(
|
||||
text=texts.MENU_SIMPLE_SUBSCRIPTION,
|
||||
callback_data="simple_subscription_purchase",
|
||||
)
|
||||
|
||||
subscription_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
if show_trial:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial")
|
||||
@@ -337,71 +541,78 @@ def get_main_menu_keyboard(
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
|
||||
)
|
||||
|
||||
# Добавляем кнопку простой покупки после кнопки "Купить подписку"
|
||||
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
subscription_buttons.append(
|
||||
InlineKeyboardButton(text="⚡ Простая покупка", callback_data="simple_subscription_purchase")
|
||||
)
|
||||
|
||||
if subscription_buttons:
|
||||
if len(subscription_buttons) == 2:
|
||||
keyboard.append(subscription_buttons)
|
||||
elif len(subscription_buttons) == 1:
|
||||
keyboard.append([subscription_buttons[0]])
|
||||
elif len(subscription_buttons) > 2:
|
||||
# Если больше 2 кнопок, добавляем по отдельности
|
||||
for button in subscription_buttons:
|
||||
keyboard.append([button])
|
||||
paired_buttons.extend(subscription_buttons)
|
||||
if simple_purchase_button:
|
||||
paired_buttons.append(simple_purchase_button)
|
||||
|
||||
if show_resume_checkout or has_saved_cart:
|
||||
keyboard.append([
|
||||
resume_callback = (
|
||||
"return_to_saved_cart" if has_saved_cart else "subscription_resume_checkout"
|
||||
)
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
if custom_buttons:
|
||||
for button in custom_buttons:
|
||||
if isinstance(button, InlineKeyboardButton):
|
||||
keyboard.append([button])
|
||||
paired_buttons.append(button)
|
||||
|
||||
# Добавляем кнопки промокода и рефералов, учитывая настройки
|
||||
additional_buttons = [
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
|
||||
]
|
||||
)
|
||||
|
||||
# Добавляем кнопку рефералов только если программа включена
|
||||
# Добавляем кнопку рефералов, только если программа включена
|
||||
if settings.is_referral_program_enabled():
|
||||
additional_buttons.append(
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
|
||||
)
|
||||
|
||||
keyboard.append(additional_buttons)
|
||||
|
||||
# Support button is configurable (runtime via service)
|
||||
# Добавляем кнопку конкурсов
|
||||
if settings.CONTESTS_ENABLED and settings.CONTESTS_BUTTON_VISIBLE:
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.t("CONTESTS_BUTTON", "🎲 Конкурсы"), callback_data="contests_menu")
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
support_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
if support_enabled:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
if support_enabled:
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
)
|
||||
|
||||
# Добавляем кнопку активации
|
||||
if settings.ACTIVATE_BUTTON_VISIBLE:
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=settings.ACTIVATE_BUTTON_TEXT, callback_data="activate_button")
|
||||
)
|
||||
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_INFO", "ℹ️ Инфо"),
|
||||
callback_data="menu_info",
|
||||
)
|
||||
])
|
||||
)
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard.append([
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
|
||||
])
|
||||
)
|
||||
|
||||
for i in range(0, len(paired_buttons), 2):
|
||||
row = paired_buttons[i : i + 2]
|
||||
keyboard.append(row)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
|
||||
|
||||
@@ -667,7 +878,7 @@ def get_insufficient_balance_keyboard(
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
]
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
@@ -695,6 +906,7 @@ def get_subscription_keyboard(
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
has_direct_payment_methods = False
|
||||
|
||||
if has_subscription:
|
||||
subscription_link = get_display_subscription_link(subscription) if subscription else None
|
||||
@@ -799,7 +1011,7 @@ def get_payment_methods_keyboard_with_cart(
|
||||
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout"
|
||||
callback_data="return_to_saved_cart"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -856,36 +1068,59 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
def get_subscription_period_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
user: Optional[User] = None
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Generate subscription period selection keyboard with personalized pricing.
|
||||
|
||||
Args:
|
||||
language: User's language code
|
||||
user: User object for personalized discounts (None = default discounts)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with period buttons showing personalized prices
|
||||
"""
|
||||
from app.utils.price_display import calculate_user_price
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
available_periods = settings.get_available_subscription_periods()
|
||||
|
||||
period_texts = {
|
||||
14: texts.PERIOD_14_DAYS,
|
||||
30: texts.PERIOD_30_DAYS,
|
||||
60: texts.PERIOD_60_DAYS,
|
||||
90: texts.PERIOD_90_DAYS,
|
||||
180: texts.PERIOD_180_DAYS,
|
||||
360: texts.PERIOD_360_DAYS
|
||||
}
|
||||
|
||||
|
||||
for days in available_periods:
|
||||
if days in period_texts:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=period_texts[days],
|
||||
callback_data=f"period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
# Get base price for this period
|
||||
base_price = PERIOD_PRICES.get(days, 0)
|
||||
|
||||
# Calculate personalized price with user's discounts
|
||||
price_info = calculate_user_price(user, base_price, days, "period")
|
||||
|
||||
# Format period description
|
||||
period_display = format_period_description(days, language)
|
||||
|
||||
# Format button text with discount display
|
||||
button_text = format_price_button(
|
||||
period_label=period_display,
|
||||
price_info=price_info,
|
||||
format_price_func=texts.format_price,
|
||||
emphasize=False,
|
||||
add_exclamation=False
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка "Простая покупка" была убрана из выбора периода подписки
|
||||
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@@ -1060,6 +1295,7 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
|
||||
def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
has_direct_payment_methods = False
|
||||
|
||||
amount_kopeks = max(0, int(amount_kopeks or 0))
|
||||
|
||||
@@ -1075,6 +1311,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("stars")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
if settings.YOOKASSA_SBP_ENABLED:
|
||||
@@ -1084,6 +1321,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("yookassa_sbp"),
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -1091,6 +1329,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("yookassa"),
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
keyboard.append([
|
||||
@@ -1099,6 +1338,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("tribute")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
@@ -1111,6 +1351,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("mulenpay")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
keyboard.append([
|
||||
@@ -1119,6 +1360,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("wata")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([
|
||||
@@ -1127,6 +1369,16 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("pal24")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
|
||||
callback_data=_build_callback("platega"),
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([
|
||||
@@ -1135,15 +1387,33 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
callback_data=_build_callback("cryptobot")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
|
||||
callback_data="topup_support"
|
||||
)
|
||||
])
|
||||
if settings.is_heleket_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_HELEKET", "🪙 Криптовалюта (Heleket)"),
|
||||
callback_data=_build_callback("heleket")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if len(keyboard) == 1:
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
|
||||
callback_data="topup_support"
|
||||
)
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
|
||||
callback_data="payment_methods_unavailable"
|
||||
)
|
||||
])
|
||||
elif not has_direct_payment_methods and settings.is_support_topup_enabled():
|
||||
keyboard.insert(0, [
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
|
||||
@@ -1372,7 +1642,7 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
|
||||
for days in [1, 3, 7, 14]:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{days} дн{_get_days_suffix(days)}",
|
||||
text=f"{days} {_get_days_word(days)}",
|
||||
callback_data=f"autopay_days_{days}"
|
||||
)
|
||||
])
|
||||
@@ -1384,37 +1654,17 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _get_days_suffix(days: int) -> str:
|
||||
if days == 1:
|
||||
return "ь"
|
||||
elif 2 <= days <= 4:
|
||||
return "я"
|
||||
else:
|
||||
return "ей"
|
||||
def _get_days_word(days: int) -> str:
|
||||
if days % 10 == 1 and days % 100 != 11:
|
||||
return "день"
|
||||
if 2 <= days % 10 <= 4 and not (12 <= days % 100 <= 14):
|
||||
return "дня"
|
||||
return "дней"
|
||||
|
||||
|
||||
|
||||
def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
periods = [
|
||||
(14, f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"),
|
||||
(30, f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"),
|
||||
(60, f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"),
|
||||
(90, f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}")
|
||||
]
|
||||
|
||||
for days, text in periods:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
# Deprecated: get_extend_subscription_keyboard() was removed.
|
||||
# Use get_extend_subscription_keyboard_with_prices() instead for personalized pricing.
|
||||
|
||||
|
||||
def get_add_traffic_keyboard(
|
||||
@@ -1951,15 +2201,39 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
|
||||
|
||||
if isinstance(price_info, dict):
|
||||
final_price = price_info.get("final")
|
||||
original_price = price_info.get("original", 0)
|
||||
if final_price is None:
|
||||
final_price = price_info.get("original", 0)
|
||||
else:
|
||||
final_price = price_info
|
||||
original_price = price_info
|
||||
|
||||
period_display = format_period_description(days, language)
|
||||
|
||||
# Create PriceInfo from already calculated prices
|
||||
# Note: original_price and final_price are calculated in the handler
|
||||
discount_percent = 0
|
||||
if original_price > final_price and original_price > 0:
|
||||
discount_percent = ((original_price - final_price) * 100) // original_price
|
||||
|
||||
price_info_obj = PriceInfo(
|
||||
base_price=original_price,
|
||||
final_price=final_price,
|
||||
discount_percent=discount_percent
|
||||
)
|
||||
|
||||
# Format button using unified system
|
||||
button_text = format_price_button(
|
||||
period_label=period_display,
|
||||
price_info=price_info_obj,
|
||||
format_price_func=texts.format_price,
|
||||
emphasize=False,
|
||||
add_exclamation=False
|
||||
)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display} - {texts.format_price(final_price)}",
|
||||
text=button_text,
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
@@ -2074,33 +2348,39 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
if show_countries_management:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices")
|
||||
]
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
|
||||
])
|
||||
keyboard.insert(-2, [
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
|
||||
])
|
||||
|
||||
if settings.is_devices_selection_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
|
||||
callback_data="subscription_change_devices"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
|
||||
callback_data="subscription_manage_devices"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
|
||||
@@ -32,3 +32,12 @@ RULES_TEXT: |
|
||||
2. Do not distribute spam or malicious content.
|
||||
3. Respect other community members.
|
||||
|
||||
PAYMENT_METHODS_NONE_AVAILABLE: |
|
||||
💳 <b>Balance top-up methods</b>
|
||||
|
||||
⚠️ Payment methods are temporarily unavailable.
|
||||
Please try again later.
|
||||
|
||||
Choose a top-up option:
|
||||
SUPPORT_TOPUP_DISABLED: "Top-ups via support are disabled. Please use another payment method."
|
||||
|
||||
|
||||
@@ -32,3 +32,12 @@ RULES_TEXT: |
|
||||
2. Не распространяйте спам и вредоносный контент.
|
||||
3. Уважайте других пользователей.
|
||||
|
||||
PAYMENT_METHODS_NONE_AVAILABLE: |
|
||||
💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
⚠️ В данный момент способы оплаты временно недоступны.
|
||||
Попробуйте позже.
|
||||
|
||||
Выберите способ пополнения:
|
||||
SUPPORT_TOPUP_DISABLED: "Пополнение через поддержку отключено. Попробуйте другой способ оплаты."
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
|
||||
"BUY_TRAFFIC_BUTTON": "📈 Buy more traffic",
|
||||
"ADMIN_BACK_TO_ADMIN": "⬅️ Back to admin",
|
||||
"ADMIN_BACK_TO_LIST": "⬅️ Back to list",
|
||||
"ADMIN_BACK_TO_MAIN": "🏠 Back to main menu",
|
||||
@@ -48,6 +49,76 @@
|
||||
"ADMIN_CAMPAIGN_SERVERS": "🌍 Servers",
|
||||
"ADMIN_CAMPAIGN_STATS": "📊 Statistics",
|
||||
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Traffic",
|
||||
"ADMIN_CONTESTS": "🏆 Contests",
|
||||
"ADMIN_CONTESTS_CREATE": "➕ New contest",
|
||||
"ADMIN_CONTESTS_DISABLED": "Contests are disabled via CONTESTS_ENABLED.",
|
||||
"ADMIN_CONTESTS_LIST": "📋 Current contests",
|
||||
"ADMIN_CONTESTS_REFERRAL": "🤝 Referral contests",
|
||||
"ADMIN_CONTESTS_DAILY": "📆 Daily contests",
|
||||
"ADMIN_CONTESTS_COMING_SOON": "Coming soon.",
|
||||
"ADMIN_CONTESTS_LIST_HEADER": "🏆 <b>Contests</b>\n",
|
||||
"ADMIN_CONTESTS_EMPTY": "No contests yet.",
|
||||
"ADMIN_CONTESTS_TITLE": "🏆 <b>Contests</b>\n\nChoose an action:",
|
||||
"ADMIN_CONTEST_MODE_PAID": "💳 Referral with purchase",
|
||||
"ADMIN_CONTEST_MODE_REGISTERED": "🧑🤝🧑 Referral registration",
|
||||
"ADMIN_CONTEST_MODE_PROMPT": "Choose qualifying rule: referral must buy a subscription or just register.",
|
||||
"ADMIN_CONTEST_ENTER_TITLE": "Enter contest title:",
|
||||
"ADMIN_CONTEST_ENTER_DESCRIPTION": "Describe the contest (or send '-' to skip):",
|
||||
"ADMIN_CONTEST_ENTER_PRIZE": "Specify prizes/rewards (or '-' to skip):",
|
||||
"ADMIN_CONTEST_ENTER_START": "Enter start date/time (dd.mm.yyyy hh:mm) in your timezone:",
|
||||
"ADMIN_CONTEST_ENTER_END": "Enter end date/time (dd.mm.yyyy hh:mm) in your timezone:",
|
||||
"ADMIN_CONTEST_ENTER_DAILY_TIME": "What time to send daily results? Multiple times allowed, comma-separated HH:MM (e.g., 12:00 or 12:00,18:00).",
|
||||
"ADMIN_CONTEST_INVALID_DATE": "Cannot parse date. Format: 01.06.2024 12:00",
|
||||
"ADMIN_CONTEST_INVALID_TIME": "Cannot parse time. Format: 12:00",
|
||||
"ADMIN_CONTEST_END_BEFORE_START": "End date must be after start date.",
|
||||
"ADMIN_CONTEST_CREATED": "Contest created!",
|
||||
"ADMIN_CONTEST_PRIZE": "Prize: {prize}",
|
||||
"ADMIN_CONTEST_LAST_DAILY": "Last digest: {date}",
|
||||
"ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Active",
|
||||
"ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Disabled",
|
||||
"ADMIN_CONTEST_TOTAL_EVENTS": "Qualified: <b>{count}</b>",
|
||||
"ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Top participants:",
|
||||
"ADMIN_CONTEST_LEADERBOARD": "📊 Leaders",
|
||||
"ADMIN_CONTEST_ENABLE": "▶️ Start",
|
||||
"ADMIN_CONTEST_DISABLE": "⏸️ Pause",
|
||||
"ADMIN_CONTEST_START_MANUAL": "🧪 Manual start",
|
||||
"ADMIN_CONTEST_EDIT_SUMMARY_TIMES": "🕒 Daily summaries",
|
||||
"ADMIN_CONTEST_DELETE": "🗑 Delete",
|
||||
"ADMIN_CONTEST_DELETE_RESTRICT": "Only finished contests can be deleted.",
|
||||
"ADMIN_CONTEST_DELETED": "Contest deleted.",
|
||||
"ADMIN_CONTEST_NOT_FOUND": "Contest not found.",
|
||||
"ADMIN_CONTEST_EMPTY_LEADERBOARD": "No participants yet.",
|
||||
"ADMIN_DAILY_CONTESTS_TITLE": "📆 Daily contests",
|
||||
"ADMIN_EDIT_PRIZE": "🏅 Prize (days)",
|
||||
"ADMIN_EDIT_MAX_WINNERS": "👥 Winners",
|
||||
"ADMIN_EDIT_ATTEMPTS": "🔁 Attempts",
|
||||
"ADMIN_EDIT_TIMES": "⏰ Rounds/day",
|
||||
"ADMIN_EDIT_SCHEDULE": "🕒 Schedule",
|
||||
"ADMIN_EDIT_COOLDOWN": "⌛ Duration",
|
||||
"ADMIN_EDIT_PAYLOAD": "🧩 Payload",
|
||||
"ADMIN_CONTEST_FIELD_PROMPT": "Enter new value for {label}:",
|
||||
"ADMIN_CONTEST_PAYLOAD_PROMPT": "Send JSON payload for this game:",
|
||||
"ADMIN_ROUND_STARTED": "Round started",
|
||||
"ADMIN_UPDATED": "Updated",
|
||||
"ADMIN_INVALID_NUMBER": "Invalid number",
|
||||
"ADMIN_INVALID_JSON": "Invalid JSON",
|
||||
"CONTEST_MENU_TITLE": "🎲 <b>Games/Contests</b>\nChoose a game:",
|
||||
"CONTEST_EMPTY": "No games right now",
|
||||
"CONTEST_NOT_ELIGIBLE": "Games are available only with an active or trial subscription.",
|
||||
"CONTEST_ROUND_FINISHED": "Round finished or unavailable.",
|
||||
"CONTEST_ALREADY_PLAYED": "You already played this round.",
|
||||
"CONTEST_UNKNOWN": "Contest type not supported.",
|
||||
"CONTEST_QUEST_PROMPT": "Pick one of the 3×3 nodes:",
|
||||
"CONTEST_LOCKS_PROMPT": "Find the hacked lock:",
|
||||
"CONTEST_SERVER_PROMPT": "Choose a server:",
|
||||
"CONTEST_CIPHER_PROMPT": "Decode: {q}",
|
||||
"CONTEST_EMOJI_PROMPT": "Guess the service: {q}",
|
||||
"CONTEST_ANAGRAM_PROMPT": "Make a word: {letters}",
|
||||
"CONTEST_BLITZ_PROMPT": "⚡️ Blitz! Press “I'm here!”",
|
||||
"CONTEST_BLITZ_BUTTON": "I'm here!",
|
||||
"CONTEST_WIN": "🎉 You win! ",
|
||||
"CONTEST_LOSE": "Wrong, try next round.",
|
||||
"CONTEST_PRIZE_GRANTED": "Bonus {days} days credited!",
|
||||
"ADMIN_CANCEL": "❌ Cancel",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Promo offers",
|
||||
@@ -130,6 +201,8 @@
|
||||
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Support",
|
||||
"ADMIN_MAIN_SYSTEM": "🛠️ System",
|
||||
"ADMIN_MAIN_TRIALS": "🧪 Trials",
|
||||
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
|
||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||
"ADMIN_MESSAGES_ALL_USERS": "📨 All users",
|
||||
@@ -164,6 +237,44 @@
|
||||
"ADMIN_MONITORING_STOP": "⏸️ Stop",
|
||||
"ADMIN_MONITORING_STOP_HARD": "⏹️ Stop",
|
||||
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications",
|
||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
|
||||
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
|
||||
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
|
||||
"ADMIN_TRIALS_TITLE": "🧪 Trial management",
|
||||
"ADMIN_TRIALS_STATS": "• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
|
||||
"ADMIN_TRIALS_RESET_BUTTON": "♻️ Reset all trials",
|
||||
"ADMIN_TRIALS_RESET_RESULT": "♻️ Reset {reset_count} trials.\n\n• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
|
||||
"ADMIN_TRIALS_RESET_TOAST": "✅ Reset completed",
|
||||
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
|
||||
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
|
||||
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
|
||||
"ADMIN_PAYMENT_STATUS_PROCESSING": "Processing",
|
||||
"ADMIN_PAYMENT_STATUS_PAID": "Paid",
|
||||
"ADMIN_PAYMENT_STATUS_FAILED": "Failed",
|
||||
"ADMIN_PAYMENT_STATUS_CANCELED": "Cancelled",
|
||||
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Unknown status",
|
||||
"ADMIN_PAYMENT_STATUS_ON_HOLD": "On hold",
|
||||
"ADMIN_PAYMENT_STATUS_EXPIRED": "Expired",
|
||||
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Payment details</b>",
|
||||
"ADMIN_PAYMENT_AMOUNT": "Amount",
|
||||
"ADMIN_PAYMENT_CREATED": "Created",
|
||||
"ADMIN_PAYMENT_EXPIRES": "Expires",
|
||||
"ADMIN_PAYMENT_GATEWAY_STATUS": "Gateway status",
|
||||
"ADMIN_PAYMENT_GATEWAY_METHOD": "Method",
|
||||
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Gateway amount",
|
||||
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Payer account",
|
||||
"ADMIN_PAYMENT_GATEWAY_ID": "Gateway ID",
|
||||
"ADMIN_PAYMENT_PAYER_AMOUNT": "Paid amount",
|
||||
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Crypto amount",
|
||||
"ADMIN_PAYMENT_HAS_LINK": "🔗 A payment link is available via the button above.",
|
||||
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Open link",
|
||||
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Check status",
|
||||
"ADMIN_PAYMENT_CHECK_HINT": "ℹ️ You can trigger a manual status check.",
|
||||
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Manual status check is not available for this invoice.",
|
||||
"ADMIN_PAYMENT_CHECK_FAILED": "Failed to refresh the payment status.",
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS": "Status updated: {status}",
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Status did not change after the check.",
|
||||
"ADMIN_PAYMENT_NOT_FOUND": "Payment not found.",
|
||||
"ADMIN_NODE_DISABLE": "⏸️ Disable",
|
||||
"ADMIN_NODE_ENABLE": "▶️ Enable",
|
||||
"ADMIN_NODE_RESTART": "🔄 Restart",
|
||||
@@ -280,6 +391,7 @@
|
||||
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance",
|
||||
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days",
|
||||
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial",
|
||||
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Promo Group",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
@@ -336,7 +448,18 @@
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group \"{name}\" updated.",
|
||||
"ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Priority: {priority}",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Enter group priority (0 = base, higher = higher priority):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Enter new priority (current: {current}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Priority",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Priority must be a non-negative integer",
|
||||
"ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Primary: {name} (Priority: {priority})",
|
||||
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Additional groups:",
|
||||
"ADMIN_USER_PROMO_GROUPS_NONE": "User has no promo groups",
|
||||
"ADMIN_USER_PROMO_GROUP_ADDED": "✅ Group «{name}» added",
|
||||
"ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Group «{name}» removed",
|
||||
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Cannot remove the last promo group",
|
||||
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Promo offers</b>\n\nSelect a template to configure:",
|
||||
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "After activation the discount lasts for {hours} h.",
|
||||
"ADMIN_PROMO_OFFER_ALLOWED": "Available segments:",
|
||||
@@ -659,6 +782,10 @@
|
||||
"ADMIN_USERS_FILTERS": "⚙️ Filters",
|
||||
"ADMIN_USERS_FILTER_ACTIVITY": "🕒 By activity",
|
||||
"ADMIN_USERS_FILTER_BALANCE": "💰 By balance",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY": "♻️ Ready to renew",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Users ready to renew",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Their subscription expired and the balance still has {amount} or more.",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "No users match this filter right now.",
|
||||
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 By campaign",
|
||||
"ADMIN_USERS_FILTER_PURCHASES": "🛒 By purchases",
|
||||
"ADMIN_USERS_FILTER_SPENDING": "💳 By spending",
|
||||
@@ -679,6 +806,22 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
|
||||
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Referrals",
|
||||
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>User referrals</b>",
|
||||
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Total referrals: {count}",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Commission percent: {percent}% (default)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Custom percent: {percent}% (default: {default_percent}%)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Change percent",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Custom referral commission</b>\n\nCurrent value: {current}%\nDefault value: {default}%\n\nSend a value from 0 to 100 or the word 'standard' to reset.",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Reset to default",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Percent updated: {percent}%",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Enter a number from 0 to 100 or the word 'standard'",
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>List of referrals:</b>",
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … and {count} more referrals",
|
||||
"ADMIN_USER_REFERRALS_EMPTY": "No referrals yet.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ To change the list, tap “✏️ Edit” below.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Edit",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
@@ -746,6 +889,10 @@
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
|
||||
"BALANCE_TOPUP": "💳 Top up balance",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Balance top-up required</b>\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 <b>Top up your balance</b> to complete the purchase.\\n\\nChoose a top-up method:",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Your {period} subscription was purchased automatically after topping up your balance.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Subscription automatically extended for {period}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ New expiration date: {date}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Open the ‘My subscription’ section to access your link and setup instructions.",
|
||||
"BALANCE_TOP_UP": "💳 Top up",
|
||||
"BLOCK_BY_TIME": "⏳ Temporary block",
|
||||
"BLOCK_FOREVER": "🚫 Block permanently",
|
||||
@@ -793,6 +940,7 @@
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Removed countries:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Reconnecting later will be charged",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
|
||||
"COUNTRIES_MINIMUM_REQUIRED": "❌ Cannot disconnect all countries. At least one country must remain connected.",
|
||||
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Manage subscription countries</b>\n\n📋 <b>Current countries ({current_count}):</b>\n{current_list}\n\n💡 <b>How it works:</b>\n✅ — currently connected\n➕ — will be added (paid)\n➖ — will be removed (free)\n⚪ — not selected\n\n⚠️ <b>Important:</b> Reconnecting removed countries will be charged again!",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Server management is unavailable — only one server is accessible",
|
||||
@@ -818,6 +966,9 @@
|
||||
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Payments are not refunded",
|
||||
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
|
||||
"DEVICES_INVALID_REQUEST": "❌ Invalid request",
|
||||
"DEVICES_INVALID_COUNT": "❌ Invalid device count",
|
||||
"DEVICES_SELECTION_DISABLED": "⚠️ Device selection is unavailable",
|
||||
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
|
||||
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
|
||||
@@ -964,7 +1115,6 @@
|
||||
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
||||
"PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
||||
"PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
|
||||
@@ -974,8 +1124,27 @@
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
|
||||
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_PLATEGA": "💳 Platega",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
|
||||
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Payment has already been credited",
|
||||
"HELEKET_PAYMENT_SUCCESS": "✅ Payment credited to your balance",
|
||||
"HELEKET_STATUS_CHECK": "⏳ Waiting for payment",
|
||||
"HELEKET_STATUS_PROCESS": "⚙️ Payment is being processed",
|
||||
"HELEKET_STATUS_CONFIRM_CHECK": "⛓ Waiting for network confirmations",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT": "❗️ Incorrect amount paid",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Not enough amount, waiting for additional payment",
|
||||
"HELEKET_STATUS_PAID_OVER": "✅ Payment credited (overpaid)",
|
||||
"HELEKET_STATUS_PAID": "✅ Payment credited",
|
||||
"HELEKET_STATUS_CANCEL": "🚫 Payment was cancelled",
|
||||
"HELEKET_STATUS_FAIL": "❌ Payment failed",
|
||||
"HELEKET_STATUS_SYSTEM_FAIL": "❌ Heleket system error",
|
||||
"HELEKET_STATUS_REFUND_PROCESS": "↩️ Refund in progress",
|
||||
"HELEKET_STATUS_REFUND_FAIL": "⚠️ Refund failed",
|
||||
"HELEKET_STATUS_REFUND_PAID": "✅ Refund completed",
|
||||
"HELEKET_STATUS_LOCKED": "🔒 Funds are locked",
|
||||
"HELEKET_STATUS_UNKNOWN": "ℹ️ Payment status: {status}",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
@@ -985,24 +1154,38 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>SBP (YooKassa)</b>",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "Provider markup",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Provider discount",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter an amount from {min_amount} to {max_amount}.\nPayment is processed by Platega.",
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Select a Platega payment method before entering the amount",
|
||||
"PLATEGA_AMOUNT_TOO_LOW": "Minimum amount for Platega: {amount}",
|
||||
"PLATEGA_AMOUNT_TOO_HIGH": "Maximum amount for Platega: {amount}",
|
||||
"PLATEGA_PAYMENT_ERROR": "❌ Failed to create Platega payment. Please try again later or contact support.",
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Payment via Platega ({method})</b>\n\n💰 Amount: {amount}\n🆔 Transaction ID: {transaction}\n\n📱 <b>Instructions:</b>\n1. Tap the ‘Pay’ button\n2. Follow the payment provider instructions\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ If you have issues, contact {support}",
|
||||
"PLATEGA_PAY_BUTTON": "💳 Pay via {method}",
|
||||
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Payment already credited",
|
||||
"PLATEGA_PAYMENT_STATUS": "Current payment status: {status}",
|
||||
"PAY_NOW_BUTTON": "💳 Pay",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
@@ -1058,7 +1241,11 @@
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
|
||||
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a referral code or promo code?</b>\n\nIf you have a promo code or referral link from a friend, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Invalid referral code or promo code",
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Invalid referral code or promo code.\n\n💡 If you have a referral code or promo code, please double-check the spelling.\n⏭️ To continue registration without a code, use the /start command.",
|
||||
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Promo code activated!\n\n{description}",
|
||||
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Promo code accepted! It will be activated after registration is complete.",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Skip",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
|
||||
@@ -1121,8 +1308,14 @@
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
|
||||
"RULES_DECLINE": "❌ I do not accept",
|
||||
"RULES_HEADER": "📋 <b>Service Rules</b>",
|
||||
"PRIVACY_POLICY_ACCEPT": "✅ Accept",
|
||||
"PRIVACY_POLICY_DECLINE": "❌ Decline",
|
||||
"PRIVACY_POLICY_REQUIRED": "You must accept the privacy policy to use the bot.",
|
||||
"PRIVACY_POLICY_ACCEPTED_PROCESSING": "✅ Privacy policy accepted! Continuing registration...",
|
||||
"ERROR_PRIVACY_POLICY_RETRY": "An error occurred. Please try accepting the privacy policy again:",
|
||||
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
|
||||
"PRIVACY_POLICY_TEXT_DEFAULT": "🔒 <b>Privacy Policy</b>\n\nWe are committed to protecting your privacy and personal data.\n\n<b>Data Collection:</b>\n• We collect only necessary information to provide services\n• Data is used exclusively for service operation\n\n<b>Data Protection:</b>\n• Your data is protected by modern encryption methods\n• We do not share data with third parties without your consent\n\n<b>Your Rights:</b>\n• You can request information about stored data\n• You can request deletion of your data\n\n<b>By using the service, you agree to the privacy policy.</b>",
|
||||
"SELECT_COUNTRIES": "Select countries:",
|
||||
"SELECT_DEVICES": "Number of devices:",
|
||||
"SELECT_PERIOD": "Choose period:",
|
||||
@@ -1150,7 +1343,7 @@
|
||||
"SKIP_BUTTON": "Skip ➡️",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\n⚠️ <b>Important:</b> Balance top-up does not automatically activate your subscription. Please activate your subscription separately!\n\n🔄 If you have a saved subscription cart and auto-purchase is enabled, your subscription will be automatically purchased after balance top-up.\n\nThank you for topping up! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
|
||||
@@ -1168,7 +1361,7 @@
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\nℹ️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n{subscription_url}",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
|
||||
@@ -1245,6 +1438,8 @@
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
|
||||
"SUB_STATUS_DISABLED": "⚫ Disabled",
|
||||
"SUB_STATUS_PENDING": "⏳ Pending activation",
|
||||
"SUB_STATUS_NONE": "❌ Not available",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
|
||||
@@ -1315,10 +1510,19 @@
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
|
||||
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_UNLIMITED_SHORT": "Unlimited",
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
|
||||
"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_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a trial plan{price_line}:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
|
||||
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Activation price:</b> {price}",
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Not enough funds to activate the trial.\nRequired: {required}\nOn balance: {balance}\nMissing: {missing}\n\nTop up your balance and try again.",
|
||||
"TRIAL_PAYMENT_FAILED": "We couldn't charge your balance to activate the trial. Please try again later.",
|
||||
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
|
||||
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
|
||||
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
|
||||
"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",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
|
||||
@@ -1347,5 +1551,59 @@
|
||||
"WATA_TOPUP_PROMPT": "💳 <b>Pay via WATA</b>\n\nEnter the amount to top up. Minimum amount — {min_amount}, maximum — {max_amount}.\nPayments are processed through the secure WATA form.",
|
||||
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
|
||||
"WELCOME_FALLBACK": "Welcome, {user_name}!",
|
||||
"YES": "✅ Yes"
|
||||
"YES": "✅ Yes",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE": "ℹ️ Your trial subscription is active for another {days} days. Purchasing a new plan will extend the validity.",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL": "ℹ️ Your trial is still running (expires in {days} days). Buying a paid plan will extend it automatically.",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically",
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase",
|
||||
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Polls",
|
||||
"ADMIN_POLLS_CREATE": "➕ Create poll",
|
||||
"ADMIN_POLLS_REWARD_ENABLED": "Reward: {amount}",
|
||||
"ADMIN_POLLS_REWARD_DISABLED": "Reward disabled",
|
||||
"ADMIN_POLLS_SEND": "📤 Send",
|
||||
"ADMIN_POLLS_STATS": "📊 Stats",
|
||||
"ADMIN_POLLS_DELETE": "🗑️ Delete",
|
||||
"ADMIN_POLLS_BACK": "⬅️ Back to list",
|
||||
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ Custom filters",
|
||||
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Send",
|
||||
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Polls</b>",
|
||||
"ADMIN_POLLS_LIST_EMPTY": "No polls yet.",
|
||||
"ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}",
|
||||
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Create poll</b>\n\nEnter poll title:",
|
||||
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED": "Description omitted.",
|
||||
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}",
|
||||
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.",
|
||||
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.",
|
||||
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Send the question and answer options.\nEach line is a separate option.\nThe first line is the question text.\nSend /done when finished.",
|
||||
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Add at least one question.",
|
||||
"ADMIN_POLLS_CREATION_FINISHED": "✅ Poll “{title}” created. Questions: {count}. {reward}",
|
||||
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Provide a question and at least two answer options.",
|
||||
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Question added: “{question}”. Add another question or send /done.",
|
||||
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Questions:</b>",
|
||||
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
|
||||
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Select audience for the poll:",
|
||||
"ADMIN_POLLS_CUSTOM_PROMPT": "Choose an additional audience filter:",
|
||||
"ADMIN_POLLS_SEND_CONFIRM": "📤 Send poll “{title}” to “{audience}”? Users: {count}",
|
||||
"ADMIN_POLLS_SENDING": "📤 Sending poll...",
|
||||
"ADMIN_POLLS_SEND_RESULT": "📤 Poll finished\nDelivered: {sent}\nFailed: {failed}\nSkipped: {skipped}\nTotal: {total}",
|
||||
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Poll statistics</b>",
|
||||
"ADMIN_POLLS_STATS_OVERVIEW": "Invited: {total}\nCompleted: {completed}\nRewards paid: {reward}",
|
||||
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
|
||||
"ADMIN_POLLS_CONFIRM_DELETE": "Delete poll “{title}”?",
|
||||
"ADMIN_POLLS_DELETED": "🗑️ Poll deleted.",
|
||||
"POLL_INVITATION_REWARD": "🎁 You will receive {amount} for participating.",
|
||||
"POLL_INVITATION_START": "Tap the button below to answer the poll.",
|
||||
"POLL_START_BUTTON": "📝 Take the poll",
|
||||
"POLL_QUESTION_HEADER": "<b>Question {current}/{total}</b>",
|
||||
"POLL_ALREADY_COMPLETED": "You have already completed this poll.",
|
||||
"POLL_EMPTY": "Poll is not available yet.",
|
||||
"POLL_ERROR": "Unable to process the poll. Please try again later.",
|
||||
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
|
||||
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Добавить трафик к подписке</b>\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:",
|
||||
"BUY_TRAFFIC_BUTTON": "📈 Докупить трафик",
|
||||
"ADMIN_BACK_TO_ADMIN": "⬅️ Назад в админку",
|
||||
"ADMIN_BACK_TO_LIST": "⬅️ К списку",
|
||||
"ADMIN_BACK_TO_MAIN": "🏠 В главное меню",
|
||||
@@ -48,6 +49,79 @@
|
||||
"ADMIN_CAMPAIGN_SERVERS": "🌍 Серверы",
|
||||
"ADMIN_CAMPAIGN_STATS": "📊 Статистика",
|
||||
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Трафик",
|
||||
"ADMIN_CONTESTS": "🏆 Конкурсы",
|
||||
"ADMIN_CONTESTS_CREATE": "➕ Новый конкурс",
|
||||
"ADMIN_CONTESTS_DISABLED": "Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
|
||||
"ADMIN_CONTESTS_LIST": "📋 Текущие конкурсы",
|
||||
"ADMIN_CONTESTS_REFERRAL": "🤝 Реферальные конкурсы",
|
||||
"ADMIN_CONTESTS_DAILY": "📆 Ежедневные конкурсы",
|
||||
"ADMIN_CONTESTS_COMING_SOON": "Функционал появится позже.",
|
||||
"ADMIN_CONTESTS_LIST_HEADER": "🏆 <b>Конкурсы</b>\n",
|
||||
"ADMIN_CONTESTS_EMPTY": "Пока нет созданных конкурсов.",
|
||||
"ADMIN_CONTESTS_TITLE": "🏆 <b>Конкурсы</b>\n\nВыберите действие:",
|
||||
"ADMIN_CONTEST_MODE_PAID": "💳 Реферал с покупкой",
|
||||
"ADMIN_CONTEST_MODE_REGISTERED": "🧑🤝🧑 Просто реферал",
|
||||
"ADMIN_CONTEST_MODE_PROMPT": "Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
|
||||
"ADMIN_CONTEST_ENTER_TITLE": "Введите название конкурса:",
|
||||
"ADMIN_CONTEST_ENTER_DESCRIPTION": "Опишите конкурс (или отправьте '-' чтобы пропустить):",
|
||||
"ADMIN_CONTEST_ENTER_PRIZE": "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):",
|
||||
"ADMIN_CONTEST_ENTER_START": "Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
"ADMIN_CONTEST_ENTER_END": "Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
"ADMIN_CONTEST_ENTER_DAILY_TIME": "Во сколько отправлять ежедневные итоги? Можно несколько через запятую, формат ЧЧ:ММ (например, 12:00 или 12:00,18:00).",
|
||||
"ADMIN_CONTEST_INVALID_DATE": "Не удалось распознать дату. Формат: 01.06.2024 12:00",
|
||||
"ADMIN_CONTEST_INVALID_TIME": "Не удалось распознать время. Формат: 12:00",
|
||||
"ADMIN_CONTEST_END_BEFORE_START": "Дата окончания должна быть позже даты начала.",
|
||||
"ADMIN_CONTEST_CREATED": "Конкурс создан!",
|
||||
"ADMIN_CONTEST_PRIZE": "Приз: {prize}",
|
||||
"ADMIN_CONTEST_LAST_DAILY": "Последняя сводка: {date}",
|
||||
"ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Активен",
|
||||
"ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Выключен",
|
||||
"ADMIN_CONTEST_TOTAL_EVENTS": "Зачётов: <b>{count}</b>",
|
||||
"ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Топ участников:",
|
||||
"ADMIN_CONTEST_LEADERBOARD": "📊 Лидеры",
|
||||
"ADMIN_CONTEST_ENABLE": "▶️ Запустить",
|
||||
"ADMIN_CONTEST_DISABLE": "⏸️ Остановить",
|
||||
"ADMIN_CONTEST_START_MANUAL": "🧪 Ручной старт",
|
||||
"ADMIN_CONTEST_EDIT_SUMMARY_TIMES": "🕒 Итоги в день",
|
||||
"ADMIN_CONTEST_DELETE": "🗑 Удалить",
|
||||
"ADMIN_CONTEST_DELETE_RESTRICT": "Удалять можно только завершённые конкурсы.",
|
||||
"ADMIN_CONTEST_DELETED": "Конкурс удалён.",
|
||||
"ADMIN_CONTEST_NOT_FOUND": "Конкурс не найден.",
|
||||
"ADMIN_CONTEST_EMPTY_LEADERBOARD": "Пока нет участников.",
|
||||
"ADMIN_DAILY_CONTESTS_TITLE": "📆 Ежедневные конкурсы",
|
||||
"ADMIN_EDIT_PRIZE": "🏅 Приз (дни)",
|
||||
"ADMIN_EDIT_MAX_WINNERS": "👥 Победителей",
|
||||
"ADMIN_EDIT_ATTEMPTS": "🔁 Попытки",
|
||||
"ADMIN_EDIT_TIMES": "⏰ Раундов/день",
|
||||
"ADMIN_EDIT_SCHEDULE": "🕒 Расписание",
|
||||
"ADMIN_EDIT_COOLDOWN": "⌛ Длительность",
|
||||
"ADMIN_EDIT_PAYLOAD": "🧩 Payload",
|
||||
"ADMIN_CONTEST_FIELD_PROMPT": "Введите новое значение для {label}:",
|
||||
"ADMIN_CONTEST_START_NOW": "Запустить раунд сейчас",
|
||||
"ADMIN_RESET_ATTEMPTS": "🔄 Сбросить попытки",
|
||||
"ADMIN_CLOSE_ROUND": "❌ Закрыть раунд",
|
||||
"ADMIN_ROUND_STARTED": "Раунд запущен",
|
||||
"ADMIN_UPDATED": "Обновлено",
|
||||
"ADMIN_INVALID_NUMBER": "Некорректное число",
|
||||
"ADMIN_INVALID_JSON": "Некорректный JSON",
|
||||
"CONTEST_MENU_TITLE": "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:",
|
||||
"CONTESTS_BUTTON": "🎲 Конкурсы",
|
||||
"CONTEST_EMPTY": "Сейчас игр нет",
|
||||
"CONTEST_NOT_ELIGIBLE": "Игры доступны только с активной или триальной подпиской.",
|
||||
"CONTEST_ROUND_FINISHED": "Раунд завершён или недоступен.",
|
||||
"CONTEST_ALREADY_PLAYED": "У вас уже была попытка в этом раунде.",
|
||||
"CONTEST_UNKNOWN": "Тип конкурса не поддерживается.",
|
||||
"CONTEST_QUEST_PROMPT": "Выбери один из узлов 3×3:",
|
||||
"CONTEST_LOCKS_PROMPT": "Найди взломанную кнопку среди замков:",
|
||||
"CONTEST_SERVER_PROMPT": "Выбери сервер:",
|
||||
"CONTEST_CIPHER_PROMPT": "Расшифруй: {q}",
|
||||
"CONTEST_EMOJI_PROMPT": "Угадай сервис по эмодзи: {q}",
|
||||
"CONTEST_ANAGRAM_PROMPT": "Составь слово: {letters}",
|
||||
"CONTEST_BLITZ_PROMPT": "⚡️ Блиц! Нажми «Я здесь!»",
|
||||
"CONTEST_BLITZ_BUTTON": "Я здесь!",
|
||||
"CONTEST_WIN": "🎉 Победа! ",
|
||||
"CONTEST_LOSE": "Не верно, попробуй снова в следующем раунде.",
|
||||
"CONTEST_PRIZE_GRANTED": "Бонус {days} дней зачислен!",
|
||||
"ADMIN_CANCEL": "❌ Отмена",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Промо-предложения",
|
||||
@@ -130,6 +204,8 @@
|
||||
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
|
||||
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
|
||||
"ADMIN_MAIN_TRIALS": "🧪 Триалы",
|
||||
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
"ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям",
|
||||
@@ -164,6 +240,44 @@
|
||||
"ADMIN_MONITORING_STOP": "⏸️ Остановить",
|
||||
"ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить",
|
||||
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений",
|
||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
|
||||
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
|
||||
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
|
||||
"ADMIN_TRIALS_TITLE": "🧪 Управление триалами",
|
||||
"ADMIN_TRIALS_STATS": "• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
|
||||
"ADMIN_TRIALS_RESET_BUTTON": "♻️ Сбросить все триалы",
|
||||
"ADMIN_TRIALS_RESET_RESULT": "♻️ Сбросили {reset_count} триалов.\n\n• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
|
||||
"ADMIN_TRIALS_RESET_TOAST": "✅ Сброс завершен",
|
||||
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
|
||||
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
|
||||
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
|
||||
"ADMIN_PAYMENT_STATUS_PROCESSING": "Обрабатывается",
|
||||
"ADMIN_PAYMENT_STATUS_PAID": "Оплачен",
|
||||
"ADMIN_PAYMENT_STATUS_FAILED": "Ошибка",
|
||||
"ADMIN_PAYMENT_STATUS_CANCELED": "Отменён",
|
||||
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Статус неизвестен",
|
||||
"ADMIN_PAYMENT_STATUS_ON_HOLD": "На удержании",
|
||||
"ADMIN_PAYMENT_STATUS_EXPIRED": "Просрочен",
|
||||
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Детали платежа</b>",
|
||||
"ADMIN_PAYMENT_AMOUNT": "Сумма",
|
||||
"ADMIN_PAYMENT_CREATED": "Создан",
|
||||
"ADMIN_PAYMENT_EXPIRES": "Истекает",
|
||||
"ADMIN_PAYMENT_GATEWAY_STATUS": "Статус в платёжке",
|
||||
"ADMIN_PAYMENT_GATEWAY_METHOD": "Метод оплаты",
|
||||
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Сумма в платёжке",
|
||||
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Счёт плательщика",
|
||||
"ADMIN_PAYMENT_GATEWAY_ID": "ID в платёжке",
|
||||
"ADMIN_PAYMENT_PAYER_AMOUNT": "Оплачено",
|
||||
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Сумма в криптовалюте",
|
||||
"ADMIN_PAYMENT_HAS_LINK": "🔗 Ссылка на оплату доступна в кнопке выше.",
|
||||
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Открыть ссылку",
|
||||
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Проверить статус",
|
||||
"ADMIN_PAYMENT_CHECK_HINT": "ℹ️ Можно запустить ручную проверку статуса.",
|
||||
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Для этого счёта ручная проверка недоступна.",
|
||||
"ADMIN_PAYMENT_CHECK_FAILED": "Не удалось обновить статус платежа.",
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS": "Статус обновлён: {status}",
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Статус не изменился после проверки.",
|
||||
"ADMIN_PAYMENT_NOT_FOUND": "Платёж не найден.",
|
||||
"ADMIN_NODE_DISABLE": "⏸️ Отключить",
|
||||
"ADMIN_NODE_ENABLE": "▶️ Включить",
|
||||
"ADMIN_NODE_RESTART": "🔄 Перезагрузить",
|
||||
@@ -280,6 +394,7 @@
|
||||
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс",
|
||||
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки",
|
||||
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",
|
||||
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Промогруппа",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
@@ -337,6 +452,17 @@
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
|
||||
"ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Приоритет: {priority}",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Введите новый приоритет (текущий: {current}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Приоритет",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Приоритет должен быть неотрицательным целым числом",
|
||||
"ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Основная: {name} (Priority: {priority})",
|
||||
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Дополнительные группы:",
|
||||
"ADMIN_USER_PROMO_GROUPS_NONE": "У пользователя нет промогрупп",
|
||||
"ADMIN_USER_PROMO_GROUP_ADDED": "✅ Группа «{name}» добавлена",
|
||||
"ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Группа «{name}» удалена",
|
||||
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Нельзя удалить последнюю промогруппу",
|
||||
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:",
|
||||
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "Скидка после активации действует {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_ALLOWED": "Доступные категории:",
|
||||
@@ -636,6 +762,7 @@
|
||||
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
|
||||
"ADMIN_SYNC_BACK": "⬅️ К синхронизации",
|
||||
"ADMIN_SYNC_TO_PANEL": "⬆️ Синхронизация в панель",
|
||||
"ADMIN_SYNC_CLEANUP": "🧹 Очистка",
|
||||
"ADMIN_SYNC_CONFIRM": "✅ Подтвердить",
|
||||
"ADMIN_SYNC_FULL": "🔄 Полная синхронизация",
|
||||
@@ -659,6 +786,10 @@
|
||||
"ADMIN_USERS_FILTERS": "⚙️ Фильтры",
|
||||
"ADMIN_USERS_FILTER_ACTIVITY": "🕒 По активности",
|
||||
"ADMIN_USERS_FILTER_BALANCE": "💰 По балансу",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY": "♻️ Готовы к продлению",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.",
|
||||
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании",
|
||||
"ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок",
|
||||
"ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат",
|
||||
@@ -679,6 +810,33 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
|
||||
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
|
||||
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
|
||||
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Процент комиссии: {percent}% (стандартное значение)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Изменить процент",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Индивидуальный процент реферальной комиссии</b>\n\nТекущее значение: {current}%\nСтандартное значение: {default}%\n\nОтправьте новое значение от 0 до 100 или слово 'стандарт' для сброса.",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Сбросить на стандартный",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Процент обновлён: {percent}%",
|
||||
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Введите число от 0 до 100 или слово 'стандарт'",
|
||||
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
|
||||
"ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
|
||||
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать",
|
||||
"ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ <b>Редактирование рефералов</b>\n\nОтправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.",
|
||||
"ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.",
|
||||
"ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.",
|
||||
"ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}",
|
||||
"ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}",
|
||||
"ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}",
|
||||
"ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.",
|
||||
"ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}",
|
||||
"ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
@@ -745,7 +903,11 @@
|
||||
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
|
||||
"BALANCE_TOPUP": "💳 Пополнить баланс",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Требуется пополнение баланса</b>\\n\\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\\n\\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\\n\\nВыберите способ пополнения:",
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Требуется пополнение баланса</b>\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\n\nВыберите способ пополнения:",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Подписка автоматически продлена на {period}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ Новая дата окончания: {date}.",
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Перейдите в раздел «Моя подписка», чтобы получить ссылку и инструкции.",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
"BLOCK_FOREVER": "🚫 Заблокировать",
|
||||
@@ -793,6 +955,7 @@
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Отключены страны:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
|
||||
"COUNTRIES_MINIMUM_REQUIRED": "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна.",
|
||||
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Управление странами подписки</b>\n\n📋 <b>Текущие страны ({current_count}):</b>\n{current_list}\n\n💡 <b>Инструкция:</b>\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер",
|
||||
@@ -818,6 +981,9 @@
|
||||
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
|
||||
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
|
||||
"DEVICES_INVALID_REQUEST": "❌ Некорректный запрос",
|
||||
"DEVICES_INVALID_COUNT": "❌ Некорректное количество устройств",
|
||||
"DEVICES_SELECTION_DISABLED": "⚠️ Выбор количества устройств недоступен",
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
|
||||
@@ -964,7 +1130,6 @@
|
||||
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||
"PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||
"PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
||||
@@ -974,8 +1139,27 @@
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
|
||||
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_PLATEGA": "💳 Platega",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
|
||||
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен",
|
||||
"HELEKET_PAYMENT_SUCCESS": "✅ Платёж зачислен на баланс",
|
||||
"HELEKET_STATUS_CHECK": "⏳ Ожидаем оплату",
|
||||
"HELEKET_STATUS_PROCESS": "⚙️ Платёж обрабатывается",
|
||||
"HELEKET_STATUS_CONFIRM_CHECK": "⛓ Ожидание подтверждений сети",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT": "❗️ Оплачена неверная сумма",
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Недостаточная сумма, ожидаем доплату",
|
||||
"HELEKET_STATUS_PAID_OVER": "✅ Платёж зачислен (с переплатой)",
|
||||
"HELEKET_STATUS_PAID": "✅ Платёж зачислен",
|
||||
"HELEKET_STATUS_CANCEL": "🚫 Платёж отменён",
|
||||
"HELEKET_STATUS_FAIL": "❌ Ошибка при оплате",
|
||||
"HELEKET_STATUS_SYSTEM_FAIL": "❌ Системная ошибка Heleket",
|
||||
"HELEKET_STATUS_REFUND_PROCESS": "↩️ Возврат обрабатывается",
|
||||
"HELEKET_STATUS_REFUND_FAIL": "⚠️ Ошибка возврата",
|
||||
"HELEKET_STATUS_REFUND_PAID": "✅ Возврат выполнен",
|
||||
"HELEKET_STATUS_LOCKED": "🔒 Средства заблокированы",
|
||||
"HELEKET_STATUS_UNKNOWN": "ℹ️ Статус платежа: {status}",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
@@ -985,24 +1169,38 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
|
||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
|
||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
|
||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
|
||||
"PAYMENT_HELEKET_MARKUP_LABEL": "Наценка провайдера",
|
||||
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Скидка провайдера",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от {min_amount} до {max_amount}.\nОплата происходит через Platega.",
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Выберите способ оплаты Platega перед вводом суммы",
|
||||
"PLATEGA_AMOUNT_TOO_LOW": "Минимальная сумма для оплаты через Platega: {amount}",
|
||||
"PLATEGA_AMOUNT_TOO_HIGH": "Максимальная сумма для оплаты через Platega: {amount}",
|
||||
"PLATEGA_PAYMENT_ERROR": "❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Platega ({method})</b>\n\n💰 Сумма: {amount}\n🆔 ID транзакции: {transaction}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку «Оплатить»\n2. Следуйте подсказкам платёжной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PLATEGA_PAY_BUTTON": "💳 Оплатить через {method}",
|
||||
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Платёж уже зачислен",
|
||||
"PLATEGA_PAYMENT_STATUS": "Текущий статус платежа: {status}",
|
||||
"PAY_NOW_BUTTON": "💳 Оплатить",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
@@ -1058,7 +1256,11 @@
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
|
||||
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код или промокод?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Неверный реферальный код или промокод",
|
||||
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Неверный реферальный код или промокод.\n\n💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без кода используйте команду /start",
|
||||
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Промокод активирован!\n\n{description}",
|
||||
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Промокод принят! Он будет активирован после завершения регистрации.",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
|
||||
@@ -1121,8 +1323,14 @@
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
|
||||
"RULES_DECLINE": "❌ Не принимаю",
|
||||
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
|
||||
"PRIVACY_POLICY_ACCEPT": "✅ Принять",
|
||||
"PRIVACY_POLICY_DECLINE": "❌ Отклонить",
|
||||
"PRIVACY_POLICY_REQUIRED": "Для использования бота необходимо принять политику конфиденциальности.",
|
||||
"PRIVACY_POLICY_ACCEPTED_PROCESSING": "✅ Политика конфиденциальности принята! Продолжаем регистрацию...",
|
||||
"ERROR_PRIVACY_POLICY_RETRY": "Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:",
|
||||
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
|
||||
"PRIVACY_POLICY_TEXT_DEFAULT": "🔒 <b>Политика конфиденциальности</b>\n\nМы обязуемся защищать вашу конфиденциальность и личные данные.\n\n<b>Сбор данных:</b>\n• Мы собираем только необходимую информацию для предоставления услуг\n• Данные используются исключительно для работы сервиса\n\n<b>Защита данных:</b>\n• Ваши данные защищены современными методами шифрования\n• Мы не передаем данные третьим лицам без вашего согласия\n\n<b>Ваши права:</b>\n• Вы можете запросить информацию о хранимых данных\n• Вы можете запросить удаление ваших данных\n\n<b>Используя сервис, вы соглашаетесь с политикой конфиденциальности.</b>",
|
||||
"SELECT_COUNTRIES": "Выберите страны:",
|
||||
"SELECT_DEVICES": "Количество устройств:",
|
||||
"SELECT_PERIOD": "Выберите период:",
|
||||
@@ -1150,7 +1358,7 @@
|
||||
"SKIP_BUTTON": "⏭️ Пропустить",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n\n🆔 ID транзакции: {transaction_id}...\n\n⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. Обязательно активируйте подписку отдельно!\n\n🔄 При наличии сохранённой корзины подписки и включенной автопокупке, подписка будет приобретена автоматически после пополнения баланса.\n\nСпасибо за пополнение! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
|
||||
@@ -1168,7 +1376,7 @@
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n{subscription_url}",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
@@ -1245,6 +1453,8 @@
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
|
||||
"SUB_STATUS_DISABLED": "⚫ Отключена",
|
||||
"SUB_STATUS_PENDING": "⏳ Ожидает активации",
|
||||
"SUB_STATUS_NONE": "❌ Отсутствует",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
@@ -1315,10 +1525,19 @@
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
|
||||
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_UNLIMITED_SHORT": "Безлимит",
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"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_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить тестовую подписку{price_line}:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
|
||||
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Стоимость активации:</b> {price}",
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Недостаточно средств для активации триала.\nНеобходимо: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nПополните баланс и попробуйте снова.",
|
||||
"TRIAL_PAYMENT_FAILED": "Не удалось списать средства для активации триала. Попробуйте позже.",
|
||||
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
|
||||
"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",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
@@ -1347,5 +1566,59 @@
|
||||
"WATA_TOPUP_PROMPT": "💳 <b>Оплата через WATA</b>\n\nВведите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\nОплата происходит через защищенную форму WATA.",
|
||||
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
|
||||
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
|
||||
"YES": "✅ Да"
|
||||
"YES": "✅ Да",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE": "ℹ️ У вас уже есть активная триальная подписка. Она будет действовать ещё {days} дн. После покупки к сроку добавится оплаченный период.",
|
||||
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL": "ℹ️ У вас сейчас триальная подписка. Она истекает через {days} дн. Покупка новой продлит срок автоматически.",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный",
|
||||
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически",
|
||||
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
|
||||
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Опросы",
|
||||
"ADMIN_POLLS_CREATE": "➕ Создать опрос",
|
||||
"ADMIN_POLLS_REWARD_ENABLED": "Награда: {amount}",
|
||||
"ADMIN_POLLS_REWARD_DISABLED": "Награда отключена",
|
||||
"ADMIN_POLLS_SEND": "📤 Отправить",
|
||||
"ADMIN_POLLS_STATS": "📊 Статистика",
|
||||
"ADMIN_POLLS_DELETE": "🗑️ Удалить",
|
||||
"ADMIN_POLLS_BACK": "⬅️ К списку",
|
||||
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ По критериям",
|
||||
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Отправить",
|
||||
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Опросы</b>",
|
||||
"ADMIN_POLLS_LIST_EMPTY": "Опросов пока нет.",
|
||||
"ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}",
|
||||
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Создание опроса</b>\n\nВведите заголовок опроса:",
|
||||
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
|
||||
"ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED": "Описание пропущено.",
|
||||
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
|
||||
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.",
|
||||
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Введите вопрос и варианты ответов.\nКаждая строка — отдельный вариант.\nПервая строка — текст вопроса.\nОтправьте /done, когда вопросы будут добавлены.",
|
||||
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Добавьте хотя бы один вопрос.",
|
||||
"ADMIN_POLLS_CREATION_FINISHED": "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}",
|
||||
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Нужен вопрос и минимум два варианта ответа.",
|
||||
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.",
|
||||
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Вопросы:</b>",
|
||||
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
|
||||
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Выберите аудиторию для отправки опроса:",
|
||||
"ADMIN_POLLS_CUSTOM_PROMPT": "Выберите дополнительный критерий аудитории:",
|
||||
"ADMIN_POLLS_SEND_CONFIRM": "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}",
|
||||
"ADMIN_POLLS_SENDING": "📤 Запускаю отправку опроса...",
|
||||
"ADMIN_POLLS_SEND_RESULT": "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}",
|
||||
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Статистика опроса</b>",
|
||||
"ADMIN_POLLS_STATS_OVERVIEW": "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}",
|
||||
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
|
||||
"ADMIN_POLLS_CONFIRM_DELETE": "Вы уверены, что хотите удалить опрос «{title}»?",
|
||||
"ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.",
|
||||
"POLL_INVITATION_REWARD": "🎁 За участие вы получите {amount}.",
|
||||
"POLL_INVITATION_START": "Нажмите кнопку ниже, чтобы пройти опрос.",
|
||||
"POLL_START_BUTTON": "📝 Пройти опрос",
|
||||
"POLL_QUESTION_HEADER": "<b>Вопрос {current}/{total}</b>",
|
||||
"POLL_ALREADY_COMPLETED": "Вы уже прошли этот опрос.",
|
||||
"POLL_EMPTY": "Опрос пока недоступен.",
|
||||
"POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.",
|
||||
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
|
||||
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Черный список"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user