Compare commits
615 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c4aa8246c | |||
| b08be14c6a | |||
| 14d4166ac9 | |||
| c2e91265b8 | |||
| edd2809f73 | |||
| b657b9e95c | |||
| d112d769b7 | |||
| 28d26cee17 | |||
| e7115461db | |||
| c8205bd882 | |||
| 1314d1c5cb | |||
| 138a8920d9 | |||
| ac3890598f | |||
| 2784920fbb | |||
| 81e5731f34 | |||
| d637f461f9 | |||
| 7fbe5a2bd8 | |||
| 9819897dbb | |||
| d20f264b59 | |||
| 3d4e8b4d1b | |||
| 253df6a60a | |||
| fae50b2155 | |||
| 0df5fe9844 | |||
| 522146c054 | |||
| 9a3301bc38 | |||
| c169b8e69a | |||
| dbc9cbbb39 | |||
| ec768d68ef | |||
| 254255fe94 | |||
| c1aaa3acb0 | |||
| 4c2262e0b0 | |||
| 9c4445505b | |||
| b47944ef7b | |||
| 2b268a0e06 | |||
| 3ba9bddfc5 | |||
| e19f18931f | |||
| b8cbfc6065 | |||
| e7714dc33d | |||
| 847d04fa42 | |||
| 164b4f4e33 | |||
| e1b7af2990 | |||
| da74c7841d | |||
| 68d83be87a | |||
| 57f820dc35 | |||
| cd0aba5a6c | |||
| a43e68846a | |||
| adadeb4b2a | |||
| ad81cff59a | |||
| bf0c1ce711 | |||
| c71401cad4 | |||
| 81744d3de8 | |||
| f49dbeaf3a | |||
| 649bd32af3 | |||
| 55c37d8860 | |||
| c953cc150d | |||
| 59a78f0823 | |||
| 8ac3d5e826 | |||
| 744b7d532c | |||
| 72ec8d565c | |||
| 9350868830 | |||
| effbede310 | |||
| 4b3be5725c | |||
| 22ed539b3a | |||
| fa46a5b96c | |||
| 63402474d2 | |||
| 93db300dc7 | |||
| 76e29314b2 | |||
| 7e5c1d388e | |||
| 6b14ab70c6 | |||
| 5e3cff20b1 | |||
| 34c1c0b806 | |||
| e69dca013c | |||
| d201e07385 | |||
| 613e239691 | |||
| b72e00de9d | |||
| 0830461398 | |||
| 9abf0c1da9 | |||
| 74148d4e7d | |||
| 8901cad885 | |||
| a800bd4e3c | |||
| 5c3c438fb7 | |||
| 65df7ff58a | |||
| 46855439e2 | |||
| 7b3b4a8fb3 | |||
| 8a90893b40 | |||
| edf1d3dc29 | |||
| d5bf7f2749 | |||
| 5939d2d69a | |||
| 90a2fd9fdc | |||
| 841b183a59 | |||
| 9a73621ff4 | |||
| 71e8ceace9 | |||
| 4e8e11fc7b | |||
| b3a1a8ddb8 | |||
| 029d162e44 | |||
| 86ab3e544d | |||
| 587ca35049 | |||
| 02c68e5983 | |||
| 9ddc6b8f0c | |||
| eb69b166fe | |||
| 0ea6476834 | |||
| 6994735b0c | |||
| f504322afd | |||
| a60ff3bdb1 | |||
| 1eec3b71f6 | |||
| 31945067c9 | |||
| fe0da51909 | |||
| 9ddf7891aa | |||
| ce4eb3341d | |||
| f8e68de2bb | |||
| 6613de548c | |||
| 1d3f02d26a | |||
| 77d571980f | |||
| db79c538f0 | |||
| c066b9929d | |||
| ac39a22f61 | |||
| 814617bd64 | |||
| ba57848b60 | |||
| 269cbe6b2c | |||
| 07d3eff9a0 | |||
| 4e63a99197 | |||
| fe36737406 | |||
| 48917e47db | |||
| 6f348b00d3 | |||
| ea9da58e35 | |||
| 036e4d275d | |||
| cff14989ae | |||
| 10da004e5d | |||
| ed0f62542f | |||
| cc2b7c2036 | |||
| d3a87d62b4 | |||
| ef70f176dd | |||
| 7a111bae6c | |||
| 3132b32545 | |||
| 10da3595b9 | |||
| f0f3f3508f | |||
| 60b85911e1 | |||
| e78d75a440 | |||
| de57db14ea | |||
| b85d5083a9 | |||
| 862fcc53c4 | |||
| 42f1757508 | |||
| 50279979ce | |||
| ec00fe12a0 | |||
| 0806ed9f1e | |||
| 11b886d6ca | |||
| 291695395f | |||
| f787dfef04 | |||
| edc6b766cb | |||
| da2d2c826a | |||
| 0ad1f58fd4 | |||
| 9f398dbbe5 | |||
| 3a33928c9e | |||
| cf77b38fe7 | |||
| 3ab2dc0484 | |||
| 27b5f70712 | |||
| 05e47e3a1c | |||
| e85a6dd9e0 | |||
| 595c7ce5bb | |||
| 46e3433b55 | |||
| f7577c1acc | |||
| 2e57522a58 | |||
| a3ca5725a6 | |||
| 3a09240019 | |||
| b3ad09f0a1 | |||
| de515862f4 | |||
| c49e8d6c18 | |||
| decafb1d0e | |||
| 8f19a5e205 | |||
| fc75009e9b | |||
| c753f2f67e | |||
| 5a260c00d0 | |||
| 6f690a672e | |||
| 3b2c560947 | |||
| fb1641c5ba | |||
| 91615ec4b9 | |||
| fe98d4391f | |||
| 76f8ccdd85 | |||
| 45bc3bc15c | |||
| 0975fdea4d | |||
| 61bf48b7fa | |||
| 0ff4dd4321 | |||
| e0edc6bcdc | |||
| 14e58421e4 | |||
| b24ba2e75f | |||
| d9be872581 | |||
| 30ab648784 | |||
| b730de4175 | |||
| 6bfb484a9b | |||
| 0e6f6d0908 | |||
| 90a495e942 | |||
| 0182e190a8 | |||
| 30274dce20 | |||
| 0098cc724f | |||
| d9cf4008fe | |||
| ca83cf5349 | |||
| bf5e267822 | |||
| b51a0c04bc | |||
| 42e19de19c | |||
| 56c5e04c76 | |||
| 556f9dbd45 | |||
| 562fe9dccb | |||
| 960063fdca | |||
| 4a4e7406c3 | |||
| fca88c5e08 | |||
| 55dcb61798 | |||
| 3d9cc65336 | |||
| 61c69e6827 | |||
| 246c9d3935 | |||
| 7f6b0f4190 | |||
| 9e495e9f64 | |||
| 58bc8e97ee | |||
| f491a6bf00 | |||
| 8abcd0707d | |||
| b4ac9cd2bc | |||
| b18759b69f | |||
| d327ad9379 | |||
| cfe86bd8d3 | |||
| 5cb4479a70 | |||
| 64664fa30f | |||
| 15ed087142 | |||
| be6d358ed0 | |||
| dd77ecae9d | |||
| edfb325ccd | |||
| a4a6685dbb | |||
| ae9d32ceb4 | |||
| fa12448068 | |||
| a72b7ce3c8 | |||
| 1bf150f45a | |||
| e0633c409d | |||
| 2621b2f8be | |||
| ba1394c35f | |||
| 1072b64af8 | |||
| d3584d52e8 | |||
| f32b9242ba | |||
| 516a1f3e52 | |||
| 57906b1df9 | |||
| 83865dd0b9 | |||
| 69553d2551 | |||
| 02a8b932a3 | |||
| b27624021f | |||
| 4fde44eefd | |||
| 37f5977efd | |||
| a9cfbb4a4d | |||
| c7beec1df0 | |||
| 172c3426b9 | |||
| a616987eab | |||
| 0742d7cdc9 | |||
| f10a84399b | |||
| 6d2eb30704 | |||
| 618a5153f6 | |||
| 292378ebbc | |||
| 645cca088a | |||
| 4159689a4e | |||
| dd2ec37bec | |||
| 125131c8e6 | |||
| 3b895b73f9 | |||
| 39c915acc4 | |||
| 8f08489939 | |||
| baf69495d6 | |||
| 7e9c1bd784 | |||
| 509e7ffe14 | |||
| e4547b3ca0 | |||
| 5d638dbba9 | |||
| d0390bb432 | |||
| 66eaadd628 | |||
| 8a5b5599cc | |||
| a7935f3e88 | |||
| bbb89633c3 | |||
| 40a690b4bd | |||
| 4f06a183be | |||
| 6701a63a0a | |||
| 905e1f1861 | |||
| fe91fc1c29 | |||
| df8b85a179 | |||
| 8d08b23d0f | |||
| ce087beea6 | |||
| e63d2158c3 | |||
| eb64d8ed3a | |||
| fc4e17d995 | |||
| f22fd67b44 | |||
| e9ba19011a | |||
| 6569e45d9c | |||
| bc12bf47d6 | |||
| e731ccc055 | |||
| c648e78e34 | |||
| ef073bd307 | |||
| dde44a8afd | |||
| 0981aa6efa | |||
| 2b71e1701c | |||
| 2bd5c3e061 | |||
| 8f39c69f22 | |||
| b4301265ec | |||
| 7d793eff6a | |||
| 48e1485cac | |||
| f548f186de | |||
| 97ae7b6b1b | |||
| ac06b039a9 | |||
| 8514f8604a | |||
| a22b04a91e | |||
| 11f5289f2d | |||
| c72886d45a | |||
| fbfd59ca83 | |||
| 1737791659 | |||
| 3a8112b4ec | |||
| cfdb7b6bdd | |||
| 223a112d88 | |||
| b72ad3e090 | |||
| e81b555e43 | |||
| 8214775ded | |||
| 3bb5727089 | |||
| 39677eb82e | |||
| c56cdf2383 | |||
| a40c4dd7e9 | |||
| a09fd825bf | |||
| 03cf74656e | |||
| 98a301c1b5 | |||
| 1c865e3ae8 | |||
| 01388442b7 | |||
| 251a940be2 | |||
| fc725ba9e1 | |||
| 9f7e73ae2e | |||
| d576801676 | |||
| 8b82d56635 | |||
| b165fafc4d | |||
| bb9a0b305c | |||
| 99b219a307 | |||
| ad2a9b9d14 | |||
| e03bf2a393 | |||
| b6ead34e6c | |||
| cb180d9c0e | |||
| 791e26f56a | |||
| b0403dab83 | |||
| e6c3d45991 | |||
| d43597ab2a | |||
| b2bf5024a0 | |||
| 001f35ed89 | |||
| 9def85e8e2 | |||
| ed92e0cd8e | |||
| 336cfe440a | |||
| 3c312e089b | |||
| d54e42612e | |||
| 233520a656 | |||
| aeff150feb | |||
| f0d67e2428 | |||
| 72df1d91d5 | |||
| 26811cdcff | |||
| bc26d8352b | |||
| 4fe2e029bc | |||
| f54bf16ef4 | |||
| 7dfa5d5a6d | |||
| ac1f6120ab | |||
| 26c9896b51 | |||
| 3b7323c6f4 | |||
| 956936185a | |||
| 435c2d98f1 | |||
| 7f24bd7c8c | |||
| b15781ad3b | |||
| c598d66ecc | |||
| ffc969daaa | |||
| 116201cca3 | |||
| a7592836da | |||
| 734c3679ce | |||
| d350b627fc | |||
| 4fe483da56 | |||
| 8b4e1c830b | |||
| ebfb653d8b | |||
| 8b4f1672dd | |||
| e48a3f178e | |||
| a72ed1c2a1 | |||
| 15db2d975f | |||
| 99ff5f8a9d | |||
| 742093e68c | |||
| a72f6caf80 | |||
| 605d33053d | |||
| 7a2e83f3d1 | |||
| e7fc1b8402 | |||
| 5fcdcc5878 | |||
| 4212fc0be3 | |||
| df4ce40739 | |||
| b761630505 | |||
| d1c3358dbf | |||
| 1d1fc7dbfb | |||
| 25aaa703a6 | |||
| 46f3ef23ed | |||
| 80517f0c53 | |||
| 66d1f96e32 | |||
| a93c5808c0 | |||
| f80818c18a | |||
| 6199716c09 | |||
| ab131ed399 | |||
| 43203bfeb3 | |||
| 4873a26880 | |||
| 974b049ee0 | |||
| 3656ec5df5 | |||
| ef4d343e16 | |||
| 71171b24b3 | |||
| 5ce9fa9fac | |||
| 85a702b00a | |||
| aab8727de7 | |||
| 813bb45cf9 | |||
| 3d731e6b06 | |||
| 8e6174ea13 | |||
| 7a5a1fa8b4 | |||
| 1a4c8eff19 | |||
| b345a4ce25 | |||
| d8c37349ca | |||
| 4df3b2ea8f | |||
| 8bc683e03a | |||
| c176a9abf2 | |||
| 72553e5f8b | |||
| de09965876 | |||
| 7eb14ad036 | |||
| 0e0c917fdd | |||
| 7eb58d1add | |||
| 405cba645a | |||
| 359b7c807c | |||
| dbed5d9e5d | |||
| f999a31664 | |||
| 96c795c789 | |||
| 874c5d8017 | |||
| 2c76751c63 | |||
| 483b99e557 | |||
| b653c45320 | |||
| 3f0f9c3f4b | |||
| d899dc7d86 | |||
| 21567088a1 | |||
| 5425210cb0 | |||
| 0484d69538 | |||
| ac5c764daa | |||
| 15df6b802c | |||
| a1e1267239 | |||
| 5fcaa73d20 | |||
| c57d273c7e | |||
| 51a1db0680 | |||
| a3d46cd293 | |||
| 18ec1c1f5f | |||
| a9d4bea9de | |||
| 10b05f95ab | |||
| b5be8e5237 | |||
| 4117dcb97f | |||
| 0cb422d33e | |||
| da1ed55fd7 | |||
| bb17f1c456 | |||
| b2f88e75d5 | |||
| 767e7b653e | |||
| 2a7969716a | |||
| 3e10bceb43 | |||
| 6f293615bb | |||
| 69ce111281 | |||
| 85c8ad018a | |||
| 385ebd1d39 | |||
| 4227a8613e | |||
| 2dc8947b00 | |||
| 851f4dc58c | |||
| 366fc28ec5 | |||
| 277d690686 | |||
| 077584fb57 | |||
| 1bd05de773 | |||
| 1ae84a73dc | |||
| f5dad62ab0 | |||
| 2d90b9783a | |||
| dd174bf6a5 | |||
| fcd20b3aaa | |||
| d97a71d96a | |||
| e7b631a991 | |||
| 948e36478b | |||
| ec52741cf8 | |||
| 08c3a54e6d | |||
| 75af19ab83 | |||
| 13c89c1d6f | |||
| c091cb95c8 | |||
| 65aa0a7c08 | |||
| 080c019684 | |||
| 0e1962c291 | |||
| e0aea226a4 | |||
| 687ea7d1d6 | |||
| b7146268c2 | |||
| 0952059c60 | |||
| bbf5575b49 | |||
| 6b8e913f91 | |||
| 51e5f1c715 | |||
| 910f48bff0 | |||
| 62325d7a21 | |||
| dc07de9957 | |||
| d65524d349 | |||
| fd989d0186 | |||
| 390874565e | |||
| ae64dc48d2 | |||
| 8a68a0d4b1 | |||
| 82cdd4442e | |||
| 112ce528d9 | |||
| 950f84d012 | |||
| 025a57a325 | |||
| 9d374f3ba1 | |||
| d4aec3b8aa | |||
| ffe8f70946 | |||
| febf9ec669 | |||
| 6bff990d59 | |||
| d50afd94eb | |||
| d43bab9c88 | |||
| b6b63cd82d | |||
| d7534e098c | |||
| 1af55ec86e | |||
| 3b72707fd9 | |||
| 55ee06402e | |||
| bd19b570e5 | |||
| d0735aad31 | |||
| 73594b9088 | |||
| 6075ed5410 | |||
| 07ff14c6b1 | |||
| 36ac94468a | |||
| 3c3bd139e3 | |||
| 82b2127e55 | |||
| 5f2bb415de | |||
| c2a6dfb3b4 | |||
| 04bcc53012 | |||
| d9330bfb7a | |||
| 938f96bef8 | |||
| 4ea0868f6a | |||
| d032b9fcb1 | |||
| a0efbb105e | |||
| 6cfd239217 | |||
| 8378a2c555 | |||
| fd8ced4f1a | |||
| 261b5b8d14 | |||
| 0fb684544f | |||
| 22f9c9a48d | |||
| b6c3acfa13 | |||
| 8b94ac7e1d | |||
| a676e2818a | |||
| b5c2ad4c07 | |||
| d3100f6996 | |||
| 3a13c5225b | |||
| eab3626848 | |||
| 7a34a9d370 | |||
| 7045412fe6 | |||
| 7027fa91c3 | |||
| 8e9f7f91ef | |||
| e6923a0d0a | |||
| 3250976709 | |||
| cb5b731e92 | |||
| 34a7c0dfc6 | |||
| 009b6bf71d | |||
| d01da00809 | |||
| a76ca27f22 | |||
| 1a73b13d50 | |||
| 073af762c5 | |||
| b5c8b4218c | |||
| 00605a37ab | |||
| 0f61df80e2 | |||
| d1777b48ea | |||
| fae4036670 | |||
| 57a32ea52e | |||
| 5412ad4202 | |||
| ab9bc8b9e1 | |||
| 2c4090b63e | |||
| 61287d5dbc | |||
| 0cc37c1a9e | |||
| c877b327a5 | |||
| a27c9a7463 | |||
| 16c99bed06 | |||
| 045bc32798 | |||
| f1dbd7c784 | |||
| cb9c673af1 | |||
| 53ed64afa3 | |||
| 23d313e12d | |||
| 02313fc924 | |||
| 860937591d | |||
| 9388f1c81a | |||
| d7c4f439b9 | |||
| 4273d2354c | |||
| ee1c215278 | |||
| da2260f55c | |||
| bdeff1dbad | |||
| bd421f23da | |||
| c63b439722 | |||
| b501e937de | |||
| afa65e0fbd | |||
| 16945492e8 | |||
| 3c879914ca | |||
| d4f8d9b94d | |||
| f44e6aca6f | |||
| af273c5762 | |||
| c24ba07751 | |||
| 1fb1dadd9d | |||
| 83536d7f3d | |||
| c2be32783f | |||
| 6654608aba | |||
| 6f29265913 | |||
| 95b42d6e9b | |||
| e76975388e | |||
| 43ebb11793 | |||
| e6d373d9bd | |||
| bea83a3635 | |||
| ba73236ca1 | |||
| 4120d516b4 | |||
| 09a46fccad | |||
| 7b27f39f74 | |||
| e4b1754e8b | |||
| 3114bf59de | |||
| 30b87e13c6 | |||
| 45d2ca6e25 | |||
| 42e3fae980 | |||
| 07c4ab264c | |||
| bdaa3b5875 | |||
| 42696d0564 | |||
| 3c0c5a3494 | |||
| 743c3dabee | |||
| 3b8708f8e6 | |||
| 977042e66c | |||
| a91f42e9f0 | |||
| c4d5388081 | |||
| f73a0fcfe8 | |||
| 7ff6e42f5b |
@@ -280,6 +280,9 @@ PAL24_REQUEST_TIMEOUT=30
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
|
||||
MAIN_MENU_MODE=default
|
||||
|
||||
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
|
||||
HIDE_SUBSCRIPTION_LINK=false
|
||||
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.4.7-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.0-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.7-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.0-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.7-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.0-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.4.7-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.0-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.7-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.0-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.7-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.5.0-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.4.7"
|
||||
ARG VERSION="v2.5.0"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -829,6 +829,9 @@ PAL24_REQUEST_TIMEOUT=30
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
|
||||
MAIN_MENU_MODE=default
|
||||
|
||||
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
|
||||
HIDE_SUBSCRIPTION_LINK=false
|
||||
|
||||
|
||||
+486
-61
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"config": {},
|
||||
"config": {
|
||||
"additionalLocales": [
|
||||
"ru",
|
||||
"zh",
|
||||
"fa"
|
||||
],
|
||||
"branding": {
|
||||
"name": "Subscription",
|
||||
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
|
||||
"supportUrl": "https://t.me"
|
||||
}
|
||||
},
|
||||
"platforms": {
|
||||
"ios": [
|
||||
{
|
||||
@@ -12,19 +23,102 @@
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"fa": "App Store [EU]",
|
||||
"ru": "App Store [EU]",
|
||||
"zh": "App Store [EU]"
|
||||
"en": "Open in App Store [EU]",
|
||||
"fa": "باز کردن در App Store [EU]",
|
||||
"ru": "Открыть в App Store [EU]",
|
||||
"zh": "在 App Store 中打开 [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"fa": "App Store [RU]",
|
||||
"ru": "App Store [RU]",
|
||||
"zh": "App Store [RU]"
|
||||
"en": "Open in App Store [RU]",
|
||||
"fa": "باز کردن در App Store [RU]",
|
||||
"ru": "Открыть в App Store [RU]",
|
||||
"zh": "在 App Store 中打开 [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "streisand",
|
||||
"name": "Streisand",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "streisand://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shadowrocket",
|
||||
"name": "Shadowrocket",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "sub://",
|
||||
"isNeedBase64Encoding": true,
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -64,10 +158,10 @@
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"fa": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Google Play"
|
||||
"en": "Open in Google Play",
|
||||
"fa": "باز کردن در Google Play",
|
||||
"ru": "Открыть в Google Play",
|
||||
"zh": "在 Google Play 中打开"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -103,53 +197,210 @@
|
||||
"zh": "打开应用并连接到服务器"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"macos": [
|
||||
},
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"id": "clash-meta",
|
||||
"name": "Clash Meta",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"fa": "App Store [EU]",
|
||||
"ru": "App Store [EU]",
|
||||
"zh": "App Store [EU]"
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"fa": "App Store [RU]",
|
||||
"ru": "App Store [RU]",
|
||||
"zh": "App Store [RU]"
|
||||
"en": "Open in F-Droid",
|
||||
"fa": "در F-Droid باز کنید",
|
||||
"ru": "Открыть в F-Droid",
|
||||
"zh": "在 F-Droid 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
"en": "Download and install Clash Meta APK",
|
||||
"fa": "دانلود و نصب Clash Meta APK",
|
||||
"ru": "Скачайте и установите Clash Meta APK",
|
||||
"zh": "下载并安装 Clash Meta APK"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
"en": "Tap the button to import configuration",
|
||||
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
|
||||
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
|
||||
"zh": "点击按钮导入配置"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"en": "Open Clash Meta and tap on Connect",
|
||||
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
|
||||
"ru": "Откройте Clash Meta и нажмите Подключиться",
|
||||
"zh": "打开 Clash Meta 并点击连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"macos": [
|
||||
{
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
@@ -159,75 +410,249 @@
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "happ://add/",
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Download",
|
||||
"ru": "Скачать"
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Download and install Happ.",
|
||||
"ru": "Скачайте и установите Happ"
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server",
|
||||
"ru": "Выберете локацию, включить VPN"
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linux": [],
|
||||
"androidTV": [
|
||||
{
|
||||
"id": "vpn4tv",
|
||||
"name": "VPN4TV",
|
||||
"id": "new-app-androidtv-1760203310792",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "",
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
"ru": "Button TextGoogle Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение"
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linux": [],
|
||||
"appleTV": []
|
||||
"appleTV": [
|
||||
{
|
||||
"id": "new-app-appletv-1760203488851",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -10,7 +10,8 @@ from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.throttling import ThrottlingMiddleware
|
||||
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
@@ -102,6 +103,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
display_name_middleware = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_middleware)
|
||||
dp.callback_query.middleware(display_name_middleware)
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
|
||||
+78
-2
@@ -12,6 +12,12 @@ from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
|
||||
"tme",
|
||||
"joingroup",
|
||||
]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
BOT_TOKEN: str
|
||||
@@ -198,6 +204,10 @@ class Settings(BaseSettings):
|
||||
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
|
||||
MULENPAY_LANGUAGE: str = "ru"
|
||||
MULENPAY_VAT_CODE: int = 0
|
||||
|
||||
DISPLAY_NAME_BANNED_KEYWORDS: str = "\n".join(
|
||||
DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS
|
||||
)
|
||||
MULENPAY_PAYMENT_SUBJECT: int = 4
|
||||
MULENPAY_PAYMENT_MODE: int = 4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
@@ -217,6 +227,7 @@ class Settings(BaseSettings):
|
||||
PAL24_SBP_BUTTON_TEXT: Optional[str] = None
|
||||
PAL24_CARD_BUTTON_TEXT: Optional[str] = None
|
||||
|
||||
MAIN_MENU_MODE: str = "default"
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
MINIAPP_PURCHASE_URL: str = ""
|
||||
@@ -283,6 +294,29 @@ class Settings(BaseSettings):
|
||||
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
|
||||
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
|
||||
|
||||
@field_validator('MAIN_MENU_MODE', mode='before')
|
||||
@classmethod
|
||||
def normalize_main_menu_mode(cls, value: Optional[str]) -> str:
|
||||
if not value:
|
||||
return "default"
|
||||
|
||||
normalized = str(value).strip().lower()
|
||||
aliases = {
|
||||
"classic": "default",
|
||||
"default": "default",
|
||||
"full": "default",
|
||||
"standard": "default",
|
||||
"text": "text",
|
||||
"text_only": "text",
|
||||
"textual": "text",
|
||||
"minimal": "text",
|
||||
}
|
||||
|
||||
mode = aliases.get(normalized, normalized)
|
||||
if mode not in {"default", "text"}:
|
||||
raise ValueError("MAIN_MENU_MODE must be one of: default, text")
|
||||
return mode
|
||||
|
||||
@field_validator('SERVER_STATUS_MODE', mode='before')
|
||||
@classmethod
|
||||
def normalize_server_status_mode(cls, value: Optional[str]) -> str:
|
||||
@@ -455,6 +489,29 @@ class Settings(BaseSettings):
|
||||
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
return description
|
||||
|
||||
def get_display_name_banned_keywords(self) -> List[str]:
|
||||
raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS
|
||||
if raw_value is None:
|
||||
return []
|
||||
|
||||
if isinstance(raw_value, str):
|
||||
candidates = re.split(r"[\n,]+", raw_value)
|
||||
else:
|
||||
candidates = list(raw_value)
|
||||
|
||||
unique: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
normalized = str(candidate).strip().lower()
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
unique.append(normalized)
|
||||
|
||||
return unique
|
||||
|
||||
def get_autopay_warning_days(self) -> List[int]:
|
||||
try:
|
||||
@@ -491,8 +548,14 @@ class Settings(BaseSettings):
|
||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks // 100
|
||||
return f"{rubles} ₽"
|
||||
sign = "-" if price_kopeks < 0 else ""
|
||||
rubles, kopeks = divmod(abs(price_kopeks), 100)
|
||||
|
||||
if kopeks:
|
||||
value = f"{sign}{rubles}.{kopeks:02d}".rstrip("0").rstrip(".")
|
||||
return f"{value} ₽"
|
||||
|
||||
return f"{sign}{rubles} ₽"
|
||||
|
||||
def get_reports_chat_id(self) -> Optional[str]:
|
||||
if self.ADMIN_REPORTS_CHAT_ID:
|
||||
@@ -538,6 +601,19 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_notifications_enabled(self) -> bool:
|
||||
return self.ENABLE_NOTIFICATIONS
|
||||
|
||||
def get_main_menu_mode(self) -> str:
|
||||
return getattr(self, "MAIN_MENU_MODE", "default")
|
||||
|
||||
def is_text_main_menu_mode(self) -> bool:
|
||||
return self.get_main_menu_mode() == "text"
|
||||
|
||||
def get_main_menu_miniapp_url(self) -> Optional[str]:
|
||||
for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]:
|
||||
value = (candidate or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
def get_app_config_path(self) -> str:
|
||||
if os.path.isabs(self.APP_CONFIG_PATH):
|
||||
|
||||
@@ -17,7 +17,14 @@ from sqlalchemy import (
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
ServerSquad,
|
||||
SubscriptionServer,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -495,10 +502,10 @@ def _extract_country_code(original_name: str) -> Optional[str]:
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
|
||||
available_result = await db.execute(
|
||||
select(func.count(ServerSquad.id))
|
||||
.where(ServerSquad.is_available == True)
|
||||
@@ -537,6 +544,25 @@ async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
'total_revenue_rubles': total_revenue_kopeks / 100
|
||||
}
|
||||
|
||||
|
||||
async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int:
|
||||
"""Возвращает количество активных подписок, подключенных к указанному скваду."""
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
]
|
||||
),
|
||||
cast(Subscription.connected_squads, String).like(f'%"{squad_uuid}"%'),
|
||||
)
|
||||
)
|
||||
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def add_user_to_servers(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
|
||||
@@ -39,6 +39,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
@@ -56,6 +57,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
Vendored
+12
-10
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -18,8 +19,9 @@ class TelegramStarsService:
|
||||
return settings.rubles_to_stars(rubles)
|
||||
|
||||
@staticmethod
|
||||
def calculate_rubles_from_stars(stars: int) -> float:
|
||||
return settings.stars_to_rubles(stars)
|
||||
def calculate_rubles_from_stars(stars: int) -> Decimal:
|
||||
rate = Decimal(str(settings.get_stars_rate()))
|
||||
return (Decimal(stars) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
@@ -31,10 +33,10 @@ class TelegramStarsService:
|
||||
start_parameter: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
stars_amount = self.calculate_stars_from_rubles(float(amount_rubles))
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title=title,
|
||||
description=description,
|
||||
@@ -46,7 +48,7 @@ class TelegramStarsService:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽) "
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{settings.format_price(amount_kopeks)}) "
|
||||
f"для {chat_id}, курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return invoice_link
|
||||
@@ -65,8 +67,8 @@ class TelegramStarsService:
|
||||
keyboard: Optional[InlineKeyboardMarkup] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
stars_amount = self.calculate_stars_from_rubles(float(amount_rubles))
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
message = await self.bot.send_invoice(
|
||||
@@ -82,12 +84,12 @@ class TelegramStarsService:
|
||||
|
||||
logger.info(
|
||||
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
|
||||
f"(~{int(amount_rubles)}₽), курс: {stars_rate}₽/⭐"
|
||||
f"(~{settings.format_price(amount_kopeks)}), курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"stars_amount": stars_amount,
|
||||
"rubles_amount": amount_rubles,
|
||||
"rubles_amount": float(amount_rubles),
|
||||
"payload": payload
|
||||
}
|
||||
|
||||
|
||||
@@ -28,12 +28,21 @@ from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
from app.database.crud.subscription import get_expiring_subscriptions
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
|
||||
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
|
||||
|
||||
TEXT_MENU_MINIAPP_BUTTON_KEYS = {
|
||||
"balance",
|
||||
"referrals",
|
||||
"promocode",
|
||||
"connect",
|
||||
"subscription",
|
||||
}
|
||||
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
return get_updated_message_buttons_selector_keyboard(list(DEFAULT_SELECTED_BUTTONS), language)
|
||||
@@ -54,12 +63,20 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> O
|
||||
if button_key not in selected_buttons:
|
||||
continue
|
||||
button_config = button_config_map[button_key]
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=button_config["text"],
|
||||
callback_data=button_config["callback"]
|
||||
if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS:
|
||||
row_buttons.append(
|
||||
build_miniapp_or_callback_button(
|
||||
text=button_config["text"],
|
||||
callback_data=button_config["callback"],
|
||||
)
|
||||
)
|
||||
else:
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=button_config["text"],
|
||||
callback_data=button_config["callback"]
|
||||
)
|
||||
)
|
||||
)
|
||||
if row_buttons:
|
||||
keyboard.append(row_buttons)
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.subscription_utils import get_display_subscription_link
|
||||
from app.utils.formatters import format_datetime, format_duration
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1920,7 +1921,12 @@ async def _send_offer_to_users(
|
||||
|
||||
user_texts = get_texts(user.language or db_user.language)
|
||||
keyboard_rows: List[List[InlineKeyboardButton]] = [
|
||||
[InlineKeyboardButton(text=template.button_text, callback_data=f"claim_discount_{offer_record.id}")]
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=template.button_text,
|
||||
callback_data=f"claim_discount_{offer_record.id}",
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
keyboard_rows.append([
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import logging
|
||||
import math
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from app.states import SquadRenameStates, SquadCreateStates
|
||||
from app.states import SquadRenameStates, SquadCreateStates, SquadMigrationStates
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.database.crud.server_squad import (
|
||||
count_active_users_for_squad,
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_uuid,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_remnawave_keyboard, get_sync_options_keyboard,
|
||||
get_node_management_keyboard, get_confirmation_keyboard,
|
||||
get_squad_management_keyboard, get_squad_edit_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.remnawave_service import RemnaWaveService, RemnaWaveConfigurationError
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_bytes, format_datetime
|
||||
|
||||
@@ -21,6 +27,769 @@ logger = logging.getLogger(__name__)
|
||||
squad_inbound_selections = {}
|
||||
squad_create_data = {}
|
||||
|
||||
MIGRATION_PAGE_SIZE = 8
|
||||
|
||||
|
||||
def _format_migration_server_label(texts, server) -> str:
|
||||
status = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE", "✅ Доступен")
|
||||
if getattr(server, "is_available", True)
|
||||
else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE", "🚫 Недоступен")
|
||||
)
|
||||
return texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SERVER_LABEL",
|
||||
"{name} — 👥 {users} ({status})",
|
||||
).format(name=server.display_name, users=server.current_users, status=status)
|
||||
|
||||
|
||||
def _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page: int,
|
||||
total_pages: int,
|
||||
stage: str,
|
||||
*,
|
||||
exclude_uuid: str = None,
|
||||
):
|
||||
prefix = "admin_migration_source" if stage == "source" else "admin_migration_target"
|
||||
rows = []
|
||||
has_items = False
|
||||
|
||||
button_template = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON",
|
||||
"🌍 {name} — 👥 {users} ({status})",
|
||||
)
|
||||
|
||||
for squad in squads:
|
||||
if exclude_uuid and squad.squad_uuid == exclude_uuid:
|
||||
continue
|
||||
|
||||
has_items = True
|
||||
status = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT", "✅")
|
||||
if getattr(squad, "is_available", True)
|
||||
else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT", "🚫")
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=button_template.format(
|
||||
name=squad.display_name,
|
||||
users=squad.current_users,
|
||||
status=status,
|
||||
),
|
||||
callback_data=f"{prefix}_{squad.squad_uuid}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
if page > 1:
|
||||
nav_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"{prefix}_page_{page - 1}",
|
||||
)
|
||||
)
|
||||
nav_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE",
|
||||
"Стр. {page}/{pages}",
|
||||
).format(page=page, pages=total_pages),
|
||||
callback_data="admin_migration_page_info",
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
nav_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"{prefix}_page_{page + 1}",
|
||||
)
|
||||
)
|
||||
rows.append(nav_buttons)
|
||||
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.CANCEL,
|
||||
callback_data="admin_migration_cancel",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=rows), has_items
|
||||
|
||||
|
||||
async def _fetch_migration_page(
|
||||
db: AsyncSession,
|
||||
page: int,
|
||||
):
|
||||
squads, total = await get_all_server_squads(
|
||||
db,
|
||||
page=max(1, page),
|
||||
limit=MIGRATION_PAGE_SIZE,
|
||||
)
|
||||
total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE))
|
||||
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
squads, total = await get_all_server_squads(
|
||||
db,
|
||||
page=page,
|
||||
limit=MIGRATION_PAGE_SIZE,
|
||||
)
|
||||
total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE))
|
||||
|
||||
return squads, page, total_pages
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_squad_migration_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await state.clear()
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=1)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"source",
|
||||
)
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE",
|
||||
"Выберите сквад, из которого нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS",
|
||||
"Нет доступных сквадов. Добавьте новые или отмените операцию.",
|
||||
)
|
||||
)
|
||||
|
||||
await state.set_state(SquadMigrationStates.selecting_source)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def paginate_migration_source(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
if await state.get_state() != SquadMigrationStates.selecting_source:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(callback.data.split("_page_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=page)
|
||||
texts = get_texts(db_user.language)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"source",
|
||||
)
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE",
|
||||
"Выберите сквад, из которого нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS",
|
||||
"Нет доступных сквадов. Добавьте новые или отмените операцию.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_migration_source_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
if await state.get_state() != SquadMigrationStates.selecting_source:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if "_page_" in callback.data:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
source_uuid = callback.data.replace("admin_migration_source_", "", 1)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
server = await get_server_squad_by_uuid(db, source_uuid)
|
||||
|
||||
if not server:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND",
|
||||
"Сквад не найден или недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
source_uuid=server.squad_uuid,
|
||||
source_display=_format_migration_server_label(texts, server),
|
||||
)
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=1)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"target",
|
||||
exclude_uuid=server.squad_uuid,
|
||||
)
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
|
||||
"Источник: {source}",
|
||||
).format(source=_format_migration_server_label(texts, server))
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
|
||||
"Выберите сквад, в который нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
|
||||
"Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
)
|
||||
)
|
||||
|
||||
await state.set_state(SquadMigrationStates.selecting_target)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def paginate_migration_target(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
if await state.get_state() != SquadMigrationStates.selecting_target:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(callback.data.split("_page_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
if not source_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=page)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"target",
|
||||
exclude_uuid=source_uuid,
|
||||
)
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
|
||||
"Источник: {source}",
|
||||
).format(source=source_display)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
|
||||
"Выберите сквад, в который нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
|
||||
"Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_migration_target_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
if current_state != SquadMigrationStates.selecting_target:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if "_page_" in callback.data:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
|
||||
if not source_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
target_uuid = callback.data.replace("admin_migration_target_", "", 1)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if target_uuid == source_uuid:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SAME_SQUAD",
|
||||
"Нельзя выбрать тот же сквад.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
target_server = await get_server_squad_by_uuid(db, target_uuid)
|
||||
if not target_server:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND",
|
||||
"Сквад не найден или недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
|
||||
users_to_move = await count_active_users_for_squad(db, source_uuid)
|
||||
|
||||
await state.update_data(
|
||||
target_uuid=target_server.squad_uuid,
|
||||
target_display=_format_migration_server_label(texts, target_server),
|
||||
migration_count=users_to_move,
|
||||
)
|
||||
|
||||
await state.set_state(SquadMigrationStates.confirming)
|
||||
|
||||
message_lines = [
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>"),
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS",
|
||||
"Проверьте параметры переезда:",
|
||||
),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE",
|
||||
"• Из: {source}",
|
||||
).format(source=source_display),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET",
|
||||
"• В: {target}",
|
||||
).format(target=_format_migration_server_label(texts, target_server)),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT",
|
||||
"• Пользователей к переносу: {count}",
|
||||
).format(count=users_to_move),
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT",
|
||||
"Подтвердите выполнение операции.",
|
||||
),
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON",
|
||||
"✅ Подтвердить",
|
||||
),
|
||||
callback_data="admin_migration_confirm",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CHANGE_TARGET",
|
||||
"🔄 Изменить сервер назначения",
|
||||
),
|
||||
callback_data="admin_migration_change_target",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.CANCEL,
|
||||
callback_data="admin_migration_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def change_migration_target(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
|
||||
if not source_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await state.set_state(SquadMigrationStates.selecting_target)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
squads, page, total_pages = await _fetch_migration_page(db, page=1)
|
||||
keyboard, has_items = _build_migration_keyboard(
|
||||
texts,
|
||||
squads,
|
||||
page,
|
||||
total_pages,
|
||||
"target",
|
||||
exclude_uuid=source_uuid,
|
||||
)
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
|
||||
message = (
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 <b>Переезд сквадов</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
|
||||
"Источник: {source}",
|
||||
).format(source=source_display)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
|
||||
"Выберите сквад, в который нужно переехать:",
|
||||
)
|
||||
)
|
||||
|
||||
if not has_items:
|
||||
message += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
|
||||
"Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_squad_migration(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
if current_state != SquadMigrationStates.confirming:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
source_uuid = data.get("source_uuid")
|
||||
target_uuid = data.get("target_uuid")
|
||||
|
||||
if not source_uuid or not target_uuid:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
await callback.answer(texts.t("ADMIN_SQUAD_MIGRATION_IN_PROGRESS", "Запускаю переезд..."))
|
||||
|
||||
try:
|
||||
result = await remnawave_service.migrate_squad_users(
|
||||
db,
|
||||
source_uuid=source_uuid,
|
||||
target_uuid=target_uuid,
|
||||
)
|
||||
except RemnaWaveConfigurationError as error:
|
||||
message = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_API_ERROR",
|
||||
"❌ RemnaWave API не настроен: {error}",
|
||||
).format(error=str(error))
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
source_display = data.get("source_display") or source_uuid
|
||||
target_display = data.get("target_display") or target_uuid
|
||||
|
||||
if not result.get("success"):
|
||||
error_message = result.get("message") or ""
|
||||
error_code = result.get("error") or "unexpected"
|
||||
message = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_ERROR",
|
||||
"❌ Не удалось выполнить переезд (код: {code}). {details}",
|
||||
).format(code=error_code, details=error_message)
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON",
|
||||
"🔁 Новый переезд",
|
||||
),
|
||||
callback_data="admin_rw_migration",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
message_lines = [
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE", "✅ Переезд завершен"),
|
||||
"",
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE", "• Из: {source}").format(
|
||||
source=source_display
|
||||
),
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET", "• В: {target}").format(
|
||||
target=target_display
|
||||
),
|
||||
"",
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_TOTAL",
|
||||
"Найдено подписок: {count}",
|
||||
).format(count=result.get("total", 0)),
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_UPDATED",
|
||||
"Перенесено: {count}",
|
||||
).format(count=result.get("updated", 0)),
|
||||
]
|
||||
|
||||
panel_updated = result.get("panel_updated", 0)
|
||||
panel_failed = result.get("panel_failed", 0)
|
||||
|
||||
if panel_updated:
|
||||
message_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED",
|
||||
"Обновлено в панели: {count}",
|
||||
).format(count=panel_updated)
|
||||
)
|
||||
if panel_failed:
|
||||
message_lines.append(
|
||||
texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED",
|
||||
"Не удалось обновить в панели: {count}",
|
||||
).format(count=panel_failed)
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON",
|
||||
"🔁 Новый переезд",
|
||||
),
|
||||
callback_data="admin_rw_migration",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=reply_markup,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_squad_migration(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
await state.clear()
|
||||
|
||||
message = texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_CANCELLED",
|
||||
"❌ Переезд отменен.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
|
||||
"⬅️ В Remnawave",
|
||||
),
|
||||
callback_data="admin_remnawave",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_migration_page_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_SQUAD_MIGRATION_PAGE_HINT", "Это текущая страница."),
|
||||
show_alert=False,
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_remnawave_menu(
|
||||
@@ -1920,6 +2689,15 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes")
|
||||
dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync")
|
||||
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
|
||||
dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
|
||||
dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
|
||||
dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_"))
|
||||
dp.callback_query.register(paginate_migration_target, F.data.startswith("admin_migration_target_page_"))
|
||||
dp.callback_query.register(handle_migration_target_selection, F.data.startswith("admin_migration_target_"))
|
||||
dp.callback_query.register(change_migration_target, F.data == "admin_migration_change_target")
|
||||
dp.callback_query.register(confirm_squad_migration, F.data == "admin_migration_confirm")
|
||||
dp.callback_query.register(cancel_squad_migration, F.data == "admin_migration_cancel")
|
||||
dp.callback_query.register(handle_migration_page_info, F.data == "admin_migration_page_info")
|
||||
dp.callback_query.register(show_squads_management, F.data == "admin_rw_squads")
|
||||
dp.callback_query.register(show_squad_details, F.data.startswith("admin_squad_manage_"))
|
||||
dp.callback_query.register(manage_squad_action, F.data.startswith("squad_add_users_"))
|
||||
|
||||
@@ -371,7 +371,7 @@ async def handle_admin_ticket_reply(
|
||||
return
|
||||
|
||||
# Обычный режим ответа админа
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
|
||||
if not ticket:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
@@ -859,52 +859,71 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s
|
||||
except Exception:
|
||||
pass
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
# Получаем тикет с пользователем
|
||||
ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True)
|
||||
if not ticket_with_user or not ticket_with_user.user:
|
||||
|
||||
# Обеспечим наличие данных пользователя в объекте тикета
|
||||
ticket_with_user = ticket
|
||||
if not getattr(ticket_with_user, "user", None):
|
||||
ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True)
|
||||
|
||||
user = getattr(ticket_with_user, "user", None)
|
||||
if not user:
|
||||
logger.error(f"User not found for ticket #{ticket.id}")
|
||||
return
|
||||
|
||||
texts = get_texts(ticket_with_user.user.language)
|
||||
|
||||
|
||||
if not getattr(user, "telegram_id", None):
|
||||
logger.error(
|
||||
"Cannot notify ticket #%s user without telegram_id (username=%s)",
|
||||
ticket.id,
|
||||
getattr(user, "username", None),
|
||||
)
|
||||
return
|
||||
|
||||
chat_id = int(user.telegram_id)
|
||||
texts = get_texts(user.language)
|
||||
|
||||
# Формируем уведомление
|
||||
base_text = texts.t(
|
||||
"TICKET_REPLY_NOTIFICATION",
|
||||
"TICKET_REPLY_NOTIFICATION",
|
||||
"🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:"
|
||||
).format(
|
||||
ticket_id=ticket.id,
|
||||
reply_preview=reply_text[:100] + "..." if len(reply_text) > 100 else reply_text
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
|
||||
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
|
||||
])
|
||||
|
||||
# Если было фото в последнем ответе админа — отправим как фото
|
||||
last_message = await TicketMessageCRUD.get_last_message(db, ticket.id)
|
||||
if last_message and last_message.has_media and last_message.media_type == "photo" and last_message.is_from_admin:
|
||||
caption = base_text
|
||||
try:
|
||||
await bot.send_photo(
|
||||
chat_id=ticket_with_user.user.telegram_id,
|
||||
chat_id=chat_id,
|
||||
photo=last_message.media_file_id,
|
||||
caption=caption,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
|
||||
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
|
||||
])
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
except TelegramBadRequest as photo_error:
|
||||
logger.error(
|
||||
"Не удалось отправить фото-уведомление пользователю %s для тикета %s: %s",
|
||||
chat_id,
|
||||
ticket.id,
|
||||
photo_error,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить фото-уведомление: {e}")
|
||||
# Фоллбек: текстовое уведомление
|
||||
await bot.send_message(
|
||||
chat_id=ticket_with_user.user.telegram_id,
|
||||
chat_id=chat_id,
|
||||
text=base_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
|
||||
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
|
||||
])
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
logger.info(f"Ticket #{ticket.id} reply notification sent to user {ticket_with_user.user.telegram_id}")
|
||||
|
||||
|
||||
logger.info(f"Ticket #{ticket.id} reply notification sent to user {chat_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error notifying user about ticket reply: {e}")
|
||||
|
||||
|
||||
+26
-3
@@ -569,10 +569,33 @@ async def process_topup_amount(
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
try:
|
||||
amount_rubles = float(message.text.replace(',', '.'))
|
||||
|
||||
if not message.text:
|
||||
if message.successful_payment:
|
||||
logger.info(
|
||||
"Получено сообщение об успешном платеже без текста, "
|
||||
"обработчик суммы пополнения завершает работу"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
amount_text = message.text.strip()
|
||||
if not amount_text:
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
amount_rubles = float(amount_text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer("Минимальная сумма пополнения: 1 ₽")
|
||||
return
|
||||
|
||||
+40
-13
@@ -168,12 +168,14 @@ async def show_main_menu(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
custom_buttons = []
|
||||
if not settings.is_text_main_menu_mode():
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
@@ -191,11 +193,28 @@ async def show_main_menu(
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
force_text=settings.is_text_main_menu_mode(),
|
||||
)
|
||||
if not skip_callback_answer:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_profile_unavailable(callback: types.CallbackQuery) -> None:
|
||||
language = getattr(callback.from_user, "language_code", None) or settings.DEFAULT_LANGUAGE
|
||||
try:
|
||||
texts = get_texts(language)
|
||||
except Exception:
|
||||
texts = get_texts()
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"MENU_PROFILE_UNAVAILABLE",
|
||||
"❗️ Личный кабинет пока недоступен. Попробуйте позже.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
async def show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -880,12 +899,14 @@ async def handle_back_to_menu(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
custom_buttons = []
|
||||
if not settings.is_text_main_menu_mode():
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
@@ -903,6 +924,7 @@ async def handle_back_to_menu(
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
force_text=settings.is_text_main_menu_mode(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -1036,7 +1058,12 @@ def register_handlers(dp: Dispatcher):
|
||||
handle_back_to_menu,
|
||||
F.data == "back_to_menu"
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_profile_unavailable,
|
||||
F.data == "menu_profile_unavailable",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_service_rules,
|
||||
F.data == "menu_rules"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
@@ -114,6 +116,8 @@ async def handle_successful_payment(
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
amount_text = settings.format_price(amount_kopeks).replace(" ₽", "")
|
||||
|
||||
keyboard = await payment_service.build_topup_success_keyboard(user)
|
||||
|
||||
@@ -129,7 +133,7 @@ async def handle_successful_payment(
|
||||
"Спасибо за пополнение! 🚀",
|
||||
).format(
|
||||
stars_spent=payment.total_amount,
|
||||
amount=int(rubles_amount),
|
||||
amount=amount_text,
|
||||
transaction_id=transaction_id_short,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
@@ -137,8 +141,10 @@ async def handle_successful_payment(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}₽"
|
||||
"✅ Stars платеж успешно обработан: пользователь %s, %s звезд → %s",
|
||||
user.id,
|
||||
payment.total_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
else:
|
||||
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
|
||||
|
||||
+40
-30
@@ -335,12 +335,14 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
custom_buttons = []
|
||||
if not settings.is_text_main_menu_mode():
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
menu_text,
|
||||
@@ -757,12 +759,14 @@ async def complete_registration_from_callback(
|
||||
and SupportSettingsService.is_moderator(existing_user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
custom_buttons = []
|
||||
if not settings.is_text_main_menu_mode():
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
@@ -937,12 +941,14 @@ async def complete_registration_from_callback(
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
custom_buttons = []
|
||||
if not settings.is_text_main_menu_mode():
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
@@ -1011,12 +1017,14 @@ async def complete_registration(
|
||||
and SupportSettingsService.is_moderator(existing_user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
custom_buttons = []
|
||||
if not settings.is_text_main_menu_mode():
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
@@ -1191,12 +1199,14 @@ async def complete_registration(
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
custom_buttons = []
|
||||
if not settings.is_text_main_menu_mode():
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
|
||||
+247
-55
@@ -1,7 +1,9 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -56,6 +58,7 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
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
|
||||
from app.utils.pagination import paginate_list
|
||||
@@ -5485,7 +5488,7 @@ async def claim_discount_offer(
|
||||
buy_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
build_miniapp_or_callback_button(
|
||||
text=button_text,
|
||||
callback_data=button_callback,
|
||||
)
|
||||
@@ -5539,6 +5542,17 @@ async def handle_device_guide(
|
||||
return
|
||||
|
||||
featured_app = next((app for app in apps if app.get('isFeatured', False)), apps[0])
|
||||
featured_app_id = featured_app.get('id')
|
||||
other_apps = [
|
||||
app for app in apps
|
||||
if isinstance(app, dict) and app.get('id') and app.get('id') != featured_app_id
|
||||
]
|
||||
|
||||
other_app_names = ", ".join(
|
||||
str(app.get('name')).strip()
|
||||
for app in other_apps
|
||||
if isinstance(app.get('name'), str) and app.get('name').strip()
|
||||
)
|
||||
|
||||
if hide_subscription_link:
|
||||
link_section = (
|
||||
@@ -5556,6 +5570,20 @@ async def handle_device_guide(
|
||||
+ f"\n<code>{subscription_link}</code>\n\n"
|
||||
)
|
||||
|
||||
installation_description = get_step_description(featured_app, "installationStep", db_user.language)
|
||||
add_description = get_step_description(featured_app, "addSubscriptionStep", db_user.language)
|
||||
connect_description = get_step_description(featured_app, "connectAndUseStep", db_user.language)
|
||||
additional_before_text = format_additional_section(
|
||||
featured_app.get("additionalBeforeAddSubscriptionStep"),
|
||||
texts,
|
||||
db_user.language,
|
||||
)
|
||||
additional_after_text = format_additional_section(
|
||||
featured_app.get("additionalAfterAddSubscriptionStep"),
|
||||
texts,
|
||||
db_user.language,
|
||||
)
|
||||
|
||||
guide_text = (
|
||||
texts.t(
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE",
|
||||
@@ -5566,17 +5594,36 @@ async def handle_device_guide(
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP",
|
||||
"📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
).format(app_name=featured_app['name'])
|
||||
+ "\n\n"
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "<b>Шаг 1 - Установка:</b>")
|
||||
+ f"\n{featured_app['installationStep']['description'][db_user.language]}\n\n"
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "<b>Шаг 2 - Добавление подписки:</b>")
|
||||
+ f"\n{featured_app['addSubscriptionStep']['description'][db_user.language]}\n\n"
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "<b>Шаг 3 - Подключение:</b>")
|
||||
+ f"\n{featured_app['connectAndUseStep']['description'][db_user.language]}\n\n"
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 <b>Как подключить:</b>")
|
||||
+ "\n"
|
||||
+ "\n".join(
|
||||
).format(app_name=featured_app.get('name', ''))
|
||||
)
|
||||
|
||||
if other_app_names:
|
||||
guide_text += "\n\n" + texts.t(
|
||||
"SUBSCRIPTION_DEVICE_OTHER_APPS",
|
||||
"📦 <b>Другие приложения:</b> {app_list}",
|
||||
).format(app_list=other_app_names)
|
||||
guide_text += "\n" + texts.t(
|
||||
"SUBSCRIPTION_DEVICE_OTHER_APPS_HINT",
|
||||
"Нажмите кнопку \"Другие приложения\" ниже, чтобы выбрать приложение.",
|
||||
)
|
||||
|
||||
guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "<b>Шаг 1 - Установка:</b>")
|
||||
if installation_description:
|
||||
guide_text += f"\n{installation_description}"
|
||||
|
||||
if additional_before_text:
|
||||
guide_text += f"\n\n{additional_before_text}"
|
||||
|
||||
guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "<b>Шаг 2 - Добавление подписки:</b>")
|
||||
if add_description:
|
||||
guide_text += f"\n{add_description}"
|
||||
|
||||
guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "<b>Шаг 3 - Подключение:</b>")
|
||||
if connect_description:
|
||||
guide_text += f"\n{connect_description}"
|
||||
|
||||
guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 <b>Как подключить:</b>")
|
||||
guide_text += "\n" + "\n".join(
|
||||
[
|
||||
texts.t(
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1",
|
||||
@@ -5584,7 +5631,7 @@ async def handle_device_guide(
|
||||
),
|
||||
texts.t(
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2",
|
||||
"2. Скопируйте ссылку подписки (нажмите на неё)",
|
||||
"2. Нажмите кнопку \"Подключиться\" ниже",
|
||||
),
|
||||
texts.t(
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3",
|
||||
@@ -5596,14 +5643,18 @@ async def handle_device_guide(
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if additional_after_text:
|
||||
guide_text += f"\n\n{additional_after_text}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
guide_text,
|
||||
reply_markup=get_connection_guide_keyboard(
|
||||
subscription_link,
|
||||
featured_app,
|
||||
db_user.language
|
||||
device_type,
|
||||
db_user.language,
|
||||
has_other_apps=bool(other_apps),
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -5691,31 +5742,46 @@ async def handle_specific_app_guide(
|
||||
+ f"\n<code>{subscription_link}</code>\n\n"
|
||||
)
|
||||
|
||||
installation_description = get_step_description(app, "installationStep", db_user.language)
|
||||
add_description = get_step_description(app, "addSubscriptionStep", db_user.language)
|
||||
connect_description = get_step_description(app, "connectAndUseStep", db_user.language)
|
||||
additional_before_text = format_additional_section(
|
||||
app.get("additionalBeforeAddSubscriptionStep"),
|
||||
texts,
|
||||
db_user.language,
|
||||
)
|
||||
additional_after_text = format_additional_section(
|
||||
app.get("additionalAfterAddSubscriptionStep"),
|
||||
texts,
|
||||
db_user.language,
|
||||
)
|
||||
|
||||
guide_text = (
|
||||
texts.t(
|
||||
"SUBSCRIPTION_SPECIFIC_APP_TITLE",
|
||||
"📱 <b>{app_name} - {device_name}</b>",
|
||||
).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language))
|
||||
).format(app_name=app.get('name', ''), device_name=get_device_name(device_type, db_user.language))
|
||||
+ "\n\n"
|
||||
+ link_section
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "<b>Шаг 1 - Установка:</b>")
|
||||
+ f"\n{app['installationStep']['description'][db_user.language]}\n\n"
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "<b>Шаг 2 - Добавление подписки:</b>")
|
||||
+ f"\n{app['addSubscriptionStep']['description'][db_user.language]}\n\n"
|
||||
+ texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "<b>Шаг 3 - Подключение:</b>")
|
||||
+ f"\n{app['connectAndUseStep']['description'][db_user.language]}"
|
||||
)
|
||||
|
||||
if 'additionalAfterAddSubscriptionStep' in app:
|
||||
additional = app['additionalAfterAddSubscriptionStep']
|
||||
guide_text += (
|
||||
"\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE",
|
||||
"<b>{title}:</b>",
|
||||
).format(title=additional['title'][db_user.language])
|
||||
+ f"\n{additional['description'][db_user.language]}"
|
||||
)
|
||||
guide_text += texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "<b>Шаг 1 - Установка:</b>")
|
||||
if installation_description:
|
||||
guide_text += f"\n{installation_description}"
|
||||
|
||||
if additional_before_text:
|
||||
guide_text += f"\n\n{additional_before_text}"
|
||||
|
||||
guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "<b>Шаг 2 - Добавление подписки:</b>")
|
||||
if add_description:
|
||||
guide_text += f"\n{add_description}"
|
||||
|
||||
guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "<b>Шаг 3 - Подключение:</b>")
|
||||
if connect_description:
|
||||
guide_text += f"\n{connect_description}"
|
||||
|
||||
if additional_after_text:
|
||||
guide_text += f"\n\n{additional_after_text}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
guide_text,
|
||||
@@ -5858,50 +5924,176 @@ def load_app_config() -> Dict[str, Any]:
|
||||
config_path = settings.get_app_config_path()
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
logger.error("Некорректный формат app-config.json: ожидается объект")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки конфига приложений: {e}")
|
||||
return {}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def get_localized_value(values: Any, language: str, default_language: str = "en") -> str:
|
||||
if not isinstance(values, dict):
|
||||
return ""
|
||||
|
||||
candidates: List[str] = []
|
||||
normalized_language = (language or "").strip().lower()
|
||||
|
||||
if normalized_language:
|
||||
candidates.append(normalized_language)
|
||||
if "-" in normalized_language:
|
||||
candidates.append(normalized_language.split("-")[0])
|
||||
|
||||
default_language = (default_language or "").strip().lower()
|
||||
if default_language and default_language not in candidates:
|
||||
candidates.append(default_language)
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
value = values.get(candidate)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
|
||||
for value in values.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def get_step_description(app: Dict[str, Any], step_key: str, language: str) -> str:
|
||||
if not isinstance(app, dict):
|
||||
return ""
|
||||
|
||||
step = app.get(step_key)
|
||||
if not isinstance(step, dict):
|
||||
return ""
|
||||
|
||||
description = step.get("description")
|
||||
return get_localized_value(description, language)
|
||||
|
||||
|
||||
def format_additional_section(additional: Any, texts, language: str) -> str:
|
||||
if not isinstance(additional, dict):
|
||||
return ""
|
||||
|
||||
title = get_localized_value(additional.get("title"), language)
|
||||
description = get_localized_value(additional.get("description"), language)
|
||||
|
||||
parts: List[str] = []
|
||||
|
||||
if title:
|
||||
parts.append(
|
||||
texts.t(
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE",
|
||||
"<b>{title}:</b>",
|
||||
).format(title=title)
|
||||
)
|
||||
|
||||
if description:
|
||||
parts.append(description)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_redirect_link(target_link: Optional[str], template: Optional[str]) -> Optional[str]:
|
||||
if not target_link or not template:
|
||||
return None
|
||||
|
||||
normalized_target = str(target_link).strip()
|
||||
normalized_template = str(template).strip()
|
||||
|
||||
if not normalized_target or not normalized_template:
|
||||
return None
|
||||
|
||||
encoded_target = quote(normalized_target, safe="")
|
||||
result = normalized_template
|
||||
replaced = False
|
||||
|
||||
replacements = [
|
||||
("{subscription_link}", encoded_target),
|
||||
("{link}", encoded_target),
|
||||
("{subscription_link_raw}", normalized_target),
|
||||
("{link_raw}", normalized_target),
|
||||
]
|
||||
|
||||
for placeholder, replacement in replacements:
|
||||
if placeholder in result:
|
||||
result = result.replace(placeholder, replacement)
|
||||
replaced = True
|
||||
|
||||
if not replaced:
|
||||
result = f"{result}{encoded_target}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]:
|
||||
config = load_app_config()['platforms']
|
||||
config = load_app_config()
|
||||
platforms = config.get("platforms", {}) if isinstance(config, dict) else {}
|
||||
|
||||
if not isinstance(platforms, dict):
|
||||
return []
|
||||
|
||||
device_mapping = {
|
||||
'ios': 'ios',
|
||||
'android': 'android',
|
||||
'windows': 'windows',
|
||||
'mac': 'macos',
|
||||
'tv': 'androidTV'
|
||||
'tv': 'androidTV',
|
||||
'appletv': 'appleTV',
|
||||
'apple_tv': 'appleTV',
|
||||
}
|
||||
|
||||
config_key = device_mapping.get(device_type, device_type)
|
||||
return config.get(config_key, [])
|
||||
apps = platforms.get(config_key, [])
|
||||
return apps if isinstance(apps, list) else []
|
||||
|
||||
|
||||
def get_device_name(device_type: str, language: str = "ru") -> str:
|
||||
if language == "en":
|
||||
names = {
|
||||
'ios': 'iPhone/iPad',
|
||||
'android': 'Android',
|
||||
'windows': 'Windows',
|
||||
'mac': 'macOS',
|
||||
'tv': 'Android TV'
|
||||
}
|
||||
else:
|
||||
names = {
|
||||
'ios': 'iPhone/iPad',
|
||||
'android': 'Android',
|
||||
'windows': 'Windows',
|
||||
'mac': 'macOS',
|
||||
'tv': 'Android TV'
|
||||
}
|
||||
names = {
|
||||
'ios': 'iPhone/iPad',
|
||||
'android': 'Android',
|
||||
'windows': 'Windows',
|
||||
'mac': 'macOS',
|
||||
'tv': 'Android TV',
|
||||
'appletv': 'Apple TV',
|
||||
'apple_tv': 'Apple TV',
|
||||
}
|
||||
|
||||
return names.get(device_type, device_type)
|
||||
|
||||
|
||||
def create_deep_link(app: Dict[str, Any], subscription_url: str) -> str:
|
||||
return subscription_url
|
||||
def create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]:
|
||||
if not subscription_url:
|
||||
return None
|
||||
|
||||
if not isinstance(app, dict):
|
||||
return subscription_url
|
||||
|
||||
scheme = str(app.get("urlScheme", "")).strip()
|
||||
payload = subscription_url
|
||||
|
||||
if app.get("isNeedBase64Encoding"):
|
||||
try:
|
||||
payload = base64.b64encode(subscription_url.encode("utf-8")).decode("utf-8")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Не удалось закодировать ссылку подписки в base64 для приложения %s: %s",
|
||||
app.get("id"),
|
||||
exc,
|
||||
)
|
||||
payload = subscription_url
|
||||
|
||||
scheme_link = f"{scheme}{payload}" if scheme else None
|
||||
|
||||
template = settings.get_happ_cryptolink_redirect_template()
|
||||
redirect_link = build_redirect_link(scheme_link, template) if scheme_link and template else None
|
||||
|
||||
return redirect_link or scheme_link or subscription_url
|
||||
|
||||
|
||||
def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
@@ -674,6 +674,12 @@ def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="admin_rw_squads"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_REMNAWAVE_MIGRATION", "🚚 Переезд"),
|
||||
callback_data="admin_rw_migration"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_REMNAWAVE_TRAFFIC", "📈 Трафик"),
|
||||
|
||||
+229
-49
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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.subscription_utils import (
|
||||
get_display_subscription_link,
|
||||
@@ -17,6 +18,64 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_localized_value(values, language: str, default_language: str = "en") -> str:
|
||||
if not isinstance(values, dict):
|
||||
return ""
|
||||
|
||||
candidates = []
|
||||
normalized_language = (language or "").strip().lower()
|
||||
|
||||
if normalized_language:
|
||||
candidates.append(normalized_language)
|
||||
if "-" in normalized_language:
|
||||
candidates.append(normalized_language.split("-")[0])
|
||||
|
||||
default_language = (default_language or "").strip().lower()
|
||||
if default_language and default_language not in candidates:
|
||||
candidates.append(default_language)
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
value = values.get(candidate)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
|
||||
for value in values.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _build_additional_buttons(additional_section, language: str) -> List[InlineKeyboardButton]:
|
||||
if not isinstance(additional_section, dict):
|
||||
return []
|
||||
|
||||
buttons = additional_section.get("buttons")
|
||||
if not isinstance(buttons, list):
|
||||
return []
|
||||
|
||||
localized_buttons: List[InlineKeyboardButton] = []
|
||||
|
||||
for button in buttons:
|
||||
if not isinstance(button, dict):
|
||||
continue
|
||||
|
||||
button_text = _get_localized_value(button.get("buttonText"), language)
|
||||
button_link = button.get("buttonLink")
|
||||
|
||||
if not button_text or not button_link:
|
||||
continue
|
||||
|
||||
localized_buttons.append(
|
||||
InlineKeyboardButton(text=button_text, url=button_link)
|
||||
)
|
||||
|
||||
return localized_buttons
|
||||
|
||||
|
||||
_LANGUAGE_DISPLAY_NAMES = {
|
||||
"ru": "🇷🇺 Русский",
|
||||
"en": "🇬🇧 English",
|
||||
@@ -110,6 +169,59 @@ def get_language_selection_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def _build_text_main_menu_keyboard(
|
||||
language: str,
|
||||
texts,
|
||||
*,
|
||||
is_admin: bool,
|
||||
is_moderator: bool,
|
||||
) -> InlineKeyboardMarkup:
|
||||
profile_text = texts.t("MENU_PROFILE", "👤 Личный кабинет")
|
||||
miniapp_url = settings.get_main_menu_miniapp_url()
|
||||
|
||||
if miniapp_url:
|
||||
profile_button = InlineKeyboardButton(
|
||||
text=profile_text,
|
||||
web_app=types.WebAppInfo(url=miniapp_url),
|
||||
)
|
||||
else:
|
||||
profile_button = InlineKeyboardButton(
|
||||
text=profile_text,
|
||||
callback_data="menu_profile_unavailable",
|
||||
)
|
||||
|
||||
keyboard_rows: List[List[InlineKeyboardButton]] = [[profile_button]]
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
|
||||
])
|
||||
|
||||
support_enabled = False
|
||||
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_rows.append([
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
])
|
||||
|
||||
if is_admin:
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel")
|
||||
])
|
||||
elif is_moderator:
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(text="🧑⚖️ Модерация", callback_data="moderator_panel")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
|
||||
def get_main_menu_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
is_admin: bool = False,
|
||||
@@ -124,6 +236,14 @@ def get_main_menu_keyboard(
|
||||
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
if settings.is_text_main_menu_mode():
|
||||
return _build_text_main_menu_keyboard(
|
||||
language,
|
||||
texts,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}")
|
||||
@@ -1013,17 +1133,17 @@ def get_yookassa_payment_keyboard(
|
||||
|
||||
def get_autopay_notification_keyboard(subscription_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
]
|
||||
@@ -1031,23 +1151,23 @@ def get_autopay_notification_keyboard(subscription_id: int, language: str = DEFA
|
||||
|
||||
def get_subscription_expiring_keyboard(subscription_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.MENU_EXTEND_SUBSCRIPTION,
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.MENU_EXTEND_SUBSCRIPTION,
|
||||
callback_data="subscription_extend"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
]
|
||||
@@ -1547,7 +1667,8 @@ def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKey
|
||||
InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_MAC", "🎯 macOS"), callback_data="device_guide_mac")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_ANDROID_TV", "📺 Android TV"), callback_data="device_guide_tv")
|
||||
InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_ANDROID_TV", "📺 Android TV"), callback_data="device_guide_tv"),
|
||||
InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_APPLE_TV", "📺 Apple TV"), callback_data="device_guide_appletv")
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1564,42 +1685,80 @@ def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKey
|
||||
|
||||
|
||||
def get_connection_guide_keyboard(
|
||||
subscription_url: str,
|
||||
app: dict,
|
||||
language: str = DEFAULT_LANGUAGE
|
||||
subscription_url: str,
|
||||
app: dict,
|
||||
device_type: str,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
has_other_apps: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.handlers.subscription import create_deep_link
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
keyboard = []
|
||||
|
||||
|
||||
if 'installationStep' in app and 'buttons' in app['installationStep']:
|
||||
app_buttons = []
|
||||
for button in app['installationStep']['buttons']:
|
||||
button_text = button['buttonText'].get(language, button['buttonText']['en'])
|
||||
button_text = _get_localized_value(button.get('buttonText'), language)
|
||||
button_link = button.get('buttonLink')
|
||||
|
||||
if not button_text or not button_link:
|
||||
continue
|
||||
|
||||
app_buttons.append(
|
||||
InlineKeyboardButton(text=f"📥 {button_text}", url=button['buttonLink'])
|
||||
InlineKeyboardButton(text=f"📥 {button_text}", url=button_link)
|
||||
)
|
||||
if len(app_buttons) == 2:
|
||||
keyboard.append(app_buttons)
|
||||
app_buttons = []
|
||||
|
||||
|
||||
if app_buttons:
|
||||
keyboard.append(app_buttons)
|
||||
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
copy_button = InlineKeyboardButton(
|
||||
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
|
||||
|
||||
additional_before_buttons = _build_additional_buttons(
|
||||
app.get('additionalBeforeAddSubscriptionStep'),
|
||||
language,
|
||||
)
|
||||
|
||||
for button in additional_before_buttons:
|
||||
keyboard.append([button])
|
||||
|
||||
connect_link = create_deep_link(app, subscription_url)
|
||||
|
||||
if connect_link:
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=connect_link,
|
||||
)
|
||||
elif settings.is_happ_cryptolink_mode():
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="open_subscription_link",
|
||||
)
|
||||
else:
|
||||
copy_button = InlineKeyboardButton(
|
||||
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=subscription_url,
|
||||
)
|
||||
|
||||
keyboard.append([copy_button])
|
||||
|
||||
keyboard.append([connect_button])
|
||||
|
||||
additional_after_buttons = _build_additional_buttons(
|
||||
app.get('additionalAfterAddSubscriptionStep'),
|
||||
language,
|
||||
)
|
||||
|
||||
for button in additional_after_buttons:
|
||||
keyboard.append([button])
|
||||
|
||||
if has_other_apps:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("OTHER_APPS_BUTTON", "📋 Другие приложения"),
|
||||
callback_data=f"app_list_{device_type}",
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("CHOOSE_ANOTHER_DEVICE", "📱 Выбрать другое устройство"), callback_data="subscription_connect")
|
||||
@@ -1652,43 +1811,64 @@ def get_specific_app_keyboard(
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.handlers.subscription import create_deep_link
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
keyboard = []
|
||||
|
||||
|
||||
if 'installationStep' in app and 'buttons' in app['installationStep']:
|
||||
app_buttons = []
|
||||
for button in app['installationStep']['buttons']:
|
||||
button_text = button['buttonText'].get(language, button['buttonText']['en'])
|
||||
button_text = _get_localized_value(button.get('buttonText'), language)
|
||||
button_link = button.get('buttonLink')
|
||||
|
||||
if not button_text or not button_link:
|
||||
continue
|
||||
|
||||
app_buttons.append(
|
||||
InlineKeyboardButton(text=f"📥 {button_text}", url=button['buttonLink'])
|
||||
InlineKeyboardButton(text=f"📥 {button_text}", url=button_link)
|
||||
)
|
||||
if len(app_buttons) == 2:
|
||||
keyboard.append(app_buttons)
|
||||
app_buttons = []
|
||||
|
||||
|
||||
if app_buttons:
|
||||
keyboard.append(app_buttons)
|
||||
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
copy_button = InlineKeyboardButton(
|
||||
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
|
||||
|
||||
additional_before_buttons = _build_additional_buttons(
|
||||
app.get('additionalBeforeAddSubscriptionStep'),
|
||||
language,
|
||||
)
|
||||
|
||||
for button in additional_before_buttons:
|
||||
keyboard.append([button])
|
||||
|
||||
connect_link = create_deep_link(app, subscription_url)
|
||||
|
||||
if connect_link:
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=connect_link,
|
||||
)
|
||||
elif settings.is_happ_cryptolink_mode():
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="open_subscription_link",
|
||||
)
|
||||
else:
|
||||
copy_button = InlineKeyboardButton(
|
||||
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=subscription_url,
|
||||
)
|
||||
|
||||
keyboard.append([copy_button])
|
||||
|
||||
if 'additionalAfterAddSubscriptionStep' in app and 'buttons' in app['additionalAfterAddSubscriptionStep']:
|
||||
for button in app['additionalAfterAddSubscriptionStep']['buttons']:
|
||||
button_text = button['buttonText'].get(language, button['buttonText']['en'])
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=button_text, url=button['buttonLink'])
|
||||
])
|
||||
|
||||
keyboard.append([connect_button])
|
||||
|
||||
additional_after_buttons = _build_additional_buttons(
|
||||
app.get('additionalAfterAddSubscriptionStep'),
|
||||
language,
|
||||
)
|
||||
|
||||
for button in additional_after_buttons:
|
||||
keyboard.append([button])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.t("OTHER_APPS_BUTTON", "📋 Другие приложения"), callback_data=f"app_list_{device_type}")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
|
||||
"PAID_FEATURE_ONLY": "⚠ This feature is available only for paid subscriptions",
|
||||
"PAID_FEATURE_ONLY_SHORT": "⚠ Paid subscriptions only",
|
||||
"SUSPICIOUS_DISPLAY_NAME_BLOCKED": "🚫 We detected that your display name looks like a link or an official account. Please change your Telegram name and try again.",
|
||||
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ This server is not available for your promo group",
|
||||
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ No changes detected",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
|
||||
@@ -58,6 +59,7 @@
|
||||
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_APPLE_TV": "📺 Apple TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
@@ -395,6 +397,8 @@
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
|
||||
"USER_NOT_FOUND": "❌ User not found",
|
||||
"MENU_PROFILE": "👤 Personal account",
|
||||
"MENU_PROFILE_UNAVAILABLE": "❗️ Personal account is not available yet. Please try again later.",
|
||||
"MENU_LANGUAGE": "🌐 Language",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
|
||||
"SUBSCRIPTION_STATUS_TRIAL": "Trial",
|
||||
@@ -434,12 +438,14 @@
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_OTHER_APPS": "📦 <b>Other apps:</b> {app_list}",
|
||||
"SUBSCRIPTION_DEVICE_OTHER_APPS_HINT": "Tap the \"Other apps\" button below to choose another app.",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Step 2 - Add subscription:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Step 3 - Connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>How to connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Tap the \"Connect\" button below",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Apps for {device_name}</b>",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
|
||||
"PAID_FEATURE_ONLY": "⚠ Эта функция доступна только для платных подписок",
|
||||
"PAID_FEATURE_ONLY_SHORT": "⚠ Только для платных подписок",
|
||||
"SUSPICIOUS_DISPLAY_NAME_BLOCKED": "🚫 Мы обнаружили, что ваше отображаемое имя похоже на ссылку или служебный аккаунт. Пожалуйста, измените имя в профиле Telegram и повторите попытку.",
|
||||
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ Сервер недоступен для вашей промогруппы",
|
||||
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ Изменения не обнаружены",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
|
||||
@@ -183,6 +184,7 @@
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_APPLE_TV": "📺 Apple TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
@@ -254,6 +256,8 @@
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
|
||||
"MENU_PROFILE": "👤 Личный кабинет",
|
||||
"MENU_PROFILE_UNAVAILABLE": "❗️ Личный кабинет пока недоступен. Попробуйте позже.",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
@@ -444,12 +448,14 @@
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_OTHER_APPS": "📦 <b>Другие приложения:</b> {app_list}",
|
||||
"SUBSCRIPTION_DEVICE_OTHER_APPS_HINT": "Нажмите кнопку \"Другие приложения\" ниже, чтобы выбрать приложение.",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Шаг 2 - Добавление подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Шаг 3 - Подключение:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>Как подключить:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Нажмите кнопку \"Подключиться\" ниже",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Приложения для {device_name}</b>",
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Awaitable, Callable, Dict
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
Message,
|
||||
PreCheckoutQuery,
|
||||
TelegramObject,
|
||||
User as TgUser,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ZERO_WIDTH_PATTERN = re.compile(r"[\u200B-\u200D\uFEFF]")
|
||||
|
||||
LINK_PATTERNS = [
|
||||
re.compile(pattern, re.IGNORECASE)
|
||||
for pattern in (
|
||||
r"t\.me/\+",
|
||||
r"joinchat",
|
||||
r"https?://",
|
||||
r"www\.",
|
||||
r"tg://",
|
||||
r"telegram\.me",
|
||||
r"t\.me",
|
||||
)
|
||||
]
|
||||
|
||||
DOMAIN_OBFUSCATION_PATTERN = re.compile(
|
||||
r"(?<![0-9a-zа-яё])(?:t|т)[\s\W_]*?(?:m|м)(?:e|е)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
CHAR_TRANSLATION = str.maketrans({
|
||||
"а": "a",
|
||||
"е": "e",
|
||||
"о": "o",
|
||||
"р": "p",
|
||||
"с": "c",
|
||||
"х": "x",
|
||||
"у": "y",
|
||||
"к": "k",
|
||||
"т": "t",
|
||||
"г": "g",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"л": "l",
|
||||
"і": "i",
|
||||
"ї": "i",
|
||||
"ё": "e",
|
||||
"ь": "",
|
||||
"ъ": "",
|
||||
"ў": "u",
|
||||
"@": "@",
|
||||
})
|
||||
|
||||
COLLAPSE_PATTERN = re.compile(r"[\s\._\-/\\|,:;•·﹒․⋅··`~'\"!?()\[\]{}<>+=]+")
|
||||
|
||||
class DisplayNameRestrictionMiddleware(BaseMiddleware):
|
||||
"""Blocks users whose display name imitates links or official accounts."""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
user: TgUser | None = None
|
||||
|
||||
if isinstance(event, (Message, CallbackQuery, PreCheckoutQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user or user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
display_name = self._build_display_name(user)
|
||||
username = user.username or ""
|
||||
|
||||
display_suspicious = self._is_suspicious(display_name)
|
||||
username_suspicious = self._is_suspicious(username)
|
||||
|
||||
if display_suspicious or username_suspicious:
|
||||
suspicious_value = display_name if display_suspicious else username
|
||||
language = self._resolve_language(user, data)
|
||||
texts = get_texts(language)
|
||||
warning = texts.get(
|
||||
"SUSPICIOUS_DISPLAY_NAME_BLOCKED",
|
||||
"🚫 Ваше отображаемое имя похоже на ссылку или служебный аккаунт. "
|
||||
"Пожалуйста, измените имя и попробуйте снова.",
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"🚫 DisplayNameRestriction: user %s blocked due to suspicious name '%s'",
|
||||
user.id,
|
||||
suspicious_value,
|
||||
)
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer(warning)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(warning, show_alert=True)
|
||||
elif isinstance(event, PreCheckoutQuery):
|
||||
await event.answer(ok=False, error_message=warning)
|
||||
return None
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
@staticmethod
|
||||
def _build_display_name(user: TgUser) -> str:
|
||||
parts = [user.first_name or "", user.last_name or ""]
|
||||
return " ".join(part for part in parts if part).strip()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_language(user: TgUser, data: Dict[str, Any]) -> str:
|
||||
db_user = data.get("db_user")
|
||||
if db_user and getattr(db_user, "language", None):
|
||||
return db_user.language
|
||||
language_code = getattr(user, "language_code", None)
|
||||
return language_code or settings.DEFAULT_LANGUAGE
|
||||
|
||||
def _is_suspicious(self, value: str) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
|
||||
cleaned = ZERO_WIDTH_PATTERN.sub("", value)
|
||||
lower_value = cleaned.lower()
|
||||
|
||||
if "@" in cleaned or "@" in cleaned:
|
||||
return True
|
||||
|
||||
if any(pattern.search(lower_value) for pattern in LINK_PATTERNS):
|
||||
return True
|
||||
|
||||
if DOMAIN_OBFUSCATION_PATTERN.search(lower_value):
|
||||
return True
|
||||
|
||||
normalized = self._normalize_text(lower_value)
|
||||
collapsed = COLLAPSE_PATTERN.sub("", normalized)
|
||||
|
||||
if "tme" in collapsed:
|
||||
return True
|
||||
|
||||
banned_keywords = settings.get_display_name_banned_keywords()
|
||||
|
||||
return any(
|
||||
keyword in normalized or keyword in collapsed
|
||||
for keyword in banned_keywords
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_text(value: str) -> str:
|
||||
return value.translate(CHAR_TRANSLATION)
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from aiogram import Bot, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -345,35 +344,22 @@ class AdminNotificationService:
|
||||
|
||||
async def send_balance_topup_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
transaction: Transaction,
|
||||
old_balance: int
|
||||
old_balance: int,
|
||||
*,
|
||||
topup_status: str,
|
||||
referrer_info: str,
|
||||
subscription: Subscription | None,
|
||||
promo_group: PromoGroup | None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
deposit_count_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Transaction)
|
||||
.where(
|
||||
Transaction.user_id == user.id,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True)
|
||||
)
|
||||
)
|
||||
deposit_count = deposit_count_result.scalar_one() or 0
|
||||
topup_status = "🆕 Первое пополнение" if deposit_count <= 1 else "🔄 Пополнение"
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
subscription_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = subscription_result.scalar_one_or_none()
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
@@ -45,6 +45,7 @@ from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPIError,
|
||||
@@ -977,10 +978,10 @@ class MonitoringService:
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")]
|
||||
[build_miniapp_or_callback_button(text="💎 Купить подписку", callback_data="menu_buy")],
|
||||
[build_miniapp_or_callback_button(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||||
])
|
||||
|
||||
await self._send_message_with_logo(
|
||||
@@ -1033,11 +1034,11 @@ class MonitoringService:
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⏰ Продлить подписку", callback_data="subscription_extend")],
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
|
||||
[build_miniapp_or_callback_button(text="⏰ Продлить подписку", callback_data="subscription_extend")],
|
||||
[build_miniapp_or_callback_button(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||||
[build_miniapp_or_callback_button(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||||
])
|
||||
|
||||
await self._send_message_with_logo(
|
||||
@@ -1087,10 +1088,10 @@ class MonitoringService:
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")]
|
||||
[build_miniapp_or_callback_button(text="💎 Купить подписку", callback_data="menu_buy")],
|
||||
[build_miniapp_or_callback_button(text="💰 Пополнить баланс", callback_data="balance_topup")],
|
||||
])
|
||||
|
||||
await self._send_message_with_logo(
|
||||
@@ -1147,8 +1148,14 @@ class MonitoringService:
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")],
|
||||
[InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")],
|
||||
[build_miniapp_or_callback_button(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)],
|
||||
[build_miniapp_or_callback_button(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)],
|
||||
[InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
|
||||
])
|
||||
|
||||
@@ -1258,8 +1265,14 @@ class MonitoringService:
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")],
|
||||
[InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")],
|
||||
[build_miniapp_or_callback_button(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)],
|
||||
[build_miniapp_or_callback_button(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)],
|
||||
[InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
|
||||
])
|
||||
|
||||
@@ -1329,9 +1342,15 @@ class MonitoringService:
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")],
|
||||
[InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")],
|
||||
[InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")],
|
||||
[build_miniapp_or_callback_button(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")],
|
||||
[build_miniapp_or_callback_button(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)],
|
||||
[build_miniapp_or_callback_button(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)],
|
||||
[InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")],
|
||||
])
|
||||
|
||||
@@ -1394,11 +1413,11 @@ class MonitoringService:
|
||||
required=settings.format_price(required)
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
|
||||
[build_miniapp_or_callback_button(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||||
[build_miniapp_or_callback_button(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||||
])
|
||||
|
||||
await self._send_message_with_logo(
|
||||
|
||||
+155
-41
@@ -2,7 +2,7 @@ import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
@@ -24,12 +24,14 @@ from app.external.cryptobot import CryptoBotService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.database.database import get_db
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.database.crud.mulenpay import (
|
||||
create_mulenpay_payment,
|
||||
get_mulenpay_payment_by_local_id,
|
||||
@@ -70,7 +72,7 @@ class PaymentService:
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
first_button = build_miniapp_or_callback_button(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
@@ -87,14 +89,14 @@ class PaymentService:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
])
|
||||
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")
|
||||
build_miniapp_or_callback_button(text="💰 Мой баланс", callback_data="menu_balance")
|
||||
])
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")
|
||||
@@ -106,16 +108,32 @@ class PaymentService:
|
||||
self,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
payload: Optional[str] = None
|
||||
payload: Optional[str] = None,
|
||||
*,
|
||||
stars_amount: Optional[int] = None,
|
||||
) -> str:
|
||||
|
||||
|
||||
if not self.bot or not self.stars_service:
|
||||
raise ValueError("Bot instance required for Stars payments")
|
||||
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
|
||||
if stars_amount is None:
|
||||
rate = Decimal(str(settings.get_stars_rate()))
|
||||
if rate <= 0:
|
||||
raise ValueError("Stars rate must be positive")
|
||||
|
||||
normalized_stars = (amount_rubles / rate).to_integral_value(
|
||||
rounding=ROUND_FLOOR
|
||||
)
|
||||
stars_amount = int(normalized_stars)
|
||||
if stars_amount <= 0:
|
||||
stars_amount = 1
|
||||
|
||||
if stars_amount <= 0:
|
||||
raise ValueError("Stars amount must be positive")
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title="Пополнение баланса VPN",
|
||||
description=f"{description} (≈{stars_amount} ⭐)",
|
||||
@@ -125,7 +143,11 @@ class PaymentService:
|
||||
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
|
||||
)
|
||||
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽)")
|
||||
logger.info(
|
||||
"Создан Stars invoice на %s звезд (~%s)",
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
@@ -142,7 +164,7 @@ class PaymentService:
|
||||
) -> bool:
|
||||
try:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
|
||||
amount_kopeks = int(rubles_amount * 100)
|
||||
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
@@ -158,16 +180,21 @@ class PaymentService:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
|
||||
description_for_referral = f"Пополнение Stars: {int(rubles_amount)}₽ ({stars_amount} ⭐)"
|
||||
|
||||
description_for_referral = (
|
||||
f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)"
|
||||
)
|
||||
logger.info(f"🔍 Проверка реферальной логики для описания: '{description_for_referral}'")
|
||||
|
||||
if any(word in description_for_referral.lower() for word in ["пополнение", "stars", "yookassa", "topup"]) and not any(word in description_for_referral.lower() for word in ["комиссия", "бонус"]):
|
||||
@@ -179,13 +206,27 @@ class PaymentService:
|
||||
logger.error(f"Ошибка обработки реферального пополнения: {e}")
|
||||
else:
|
||||
logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики")
|
||||
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db, user, transaction, old_balance
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
@@ -206,14 +247,18 @@ class PaymentService:
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {int(rubles_amount)}₽"
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
||||
user.telegram_id,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
|
||||
logger.info(
|
||||
f"✅ Обработан Stars платеж: пользователь {user_id}, "
|
||||
f"{stars_amount} звезд → {int(rubles_amount)}₽"
|
||||
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
|
||||
user_id,
|
||||
stars_amount,
|
||||
settings.format_price(amount_kopeks),
|
||||
)
|
||||
return True
|
||||
else:
|
||||
@@ -449,25 +494,42 @@ class PaymentService:
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += updated_payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, updated_payment.amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения YooKassa: {e}")
|
||||
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db, user, transaction, old_balance
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении YooKassa: {e}")
|
||||
@@ -818,6 +880,7 @@ class PaymentService:
|
||||
language: str,
|
||||
ttl_seconds: Optional[int] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.pal24_service or not self.pal24_service.is_configured:
|
||||
@@ -848,6 +911,8 @@ class PaymentService:
|
||||
"language": language,
|
||||
}
|
||||
|
||||
normalized_payment_method = (payment_method or "SBP").upper()
|
||||
|
||||
try:
|
||||
response = await self.pal24_service.create_bill(
|
||||
amount_kopeks=amount_kopeks,
|
||||
@@ -857,7 +922,7 @@ class PaymentService:
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
payment_method="SBP",
|
||||
payment_method=normalized_payment_method,
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
@@ -952,6 +1017,7 @@ class PaymentService:
|
||||
"sbp_url": transfer_url or primary_link,
|
||||
"card_url": card_url,
|
||||
"transfer_url": transfer_url,
|
||||
"payment_method": normalized_payment_method,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -1045,11 +1111,17 @@ class PaymentService:
|
||||
return False
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
@@ -1061,6 +1133,12 @@ class PaymentService:
|
||||
referral_error,
|
||||
)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
await update_mulenpay_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
@@ -1077,10 +1155,13 @@ class PaymentService:
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
@@ -1238,10 +1319,17 @@ class PaymentService:
|
||||
await link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
@@ -1250,16 +1338,25 @@ class PaymentService:
|
||||
except Exception as referral_error:
|
||||
logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db,
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error("Ошибка отправки админ уведомления Pal24: %s", notify_error)
|
||||
@@ -1494,25 +1591,42 @@ class PaymentService:
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения CryptoBot: {e}")
|
||||
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db, user, transaction, old_balance
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении CryptoBot: {e}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -12,7 +12,8 @@ from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
|
||||
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import and_, cast, delete, func, select, update, String
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
|
||||
from app.database.crud.subscription import (
|
||||
@@ -20,9 +21,16 @@ from app.database.crud.subscription import (
|
||||
update_subscription_usage,
|
||||
decrement_subscription_server_counts,
|
||||
)
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.models import (
|
||||
User, SubscriptionServer, Transaction, ReferralEarning,
|
||||
PromoCodeUse, SubscriptionStatus
|
||||
User,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
Transaction,
|
||||
ReferralEarning,
|
||||
PromoCodeUse,
|
||||
SubscriptionStatus,
|
||||
ServerSquad,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -481,16 +489,237 @@ class RemnaWaveService:
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
result = await api.delete_internal_squad(uuid)
|
||||
|
||||
|
||||
if result:
|
||||
logger.info(f"✅ Удален сквад {uuid}")
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления сквада {uuid}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_squad_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
source_uuid: str,
|
||||
target_uuid: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Переносит активных подписок с одного сквада на другой."""
|
||||
|
||||
if source_uuid == target_uuid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "same_squad",
|
||||
"message": "Источник и назначение совпадают",
|
||||
}
|
||||
|
||||
source_uuid = source_uuid.strip()
|
||||
target_uuid = target_uuid.strip()
|
||||
|
||||
source_server = await get_server_squad_by_uuid(db, source_uuid)
|
||||
target_server = await get_server_squad_by_uuid(db, target_uuid)
|
||||
|
||||
if not source_server or not target_server:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "not_found",
|
||||
"message": "Сквады не найдены",
|
||||
}
|
||||
|
||||
subscription_query = (
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
]
|
||||
),
|
||||
cast(Subscription.connected_squads, String).like(
|
||||
f'%"{source_uuid}"%'
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(subscription_query)
|
||||
subscriptions = result.scalars().unique().all()
|
||||
|
||||
total_candidates = len(subscriptions)
|
||||
if not subscriptions:
|
||||
logger.info(
|
||||
"🚚 Переезд сквада %s → %s: подходящих подписок не найдено",
|
||||
source_uuid,
|
||||
target_uuid,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"total": 0,
|
||||
"updated": 0,
|
||||
"panel_updated": 0,
|
||||
"panel_failed": 0,
|
||||
}
|
||||
|
||||
exit_stack = AsyncExitStack()
|
||||
panel_updated = 0
|
||||
panel_failed = 0
|
||||
updated_subscriptions = 0
|
||||
source_decrement = 0
|
||||
target_increment = 0
|
||||
|
||||
try:
|
||||
needs_panel_update = any(
|
||||
subscription.user and subscription.user.remnawave_uuid
|
||||
for subscription in subscriptions
|
||||
)
|
||||
|
||||
api = None
|
||||
if needs_panel_update:
|
||||
api = await exit_stack.enter_async_context(self.get_api_client())
|
||||
|
||||
for subscription in subscriptions:
|
||||
current_squads = list(subscription.connected_squads or [])
|
||||
if source_uuid not in current_squads:
|
||||
continue
|
||||
|
||||
had_target_before = target_uuid in current_squads
|
||||
new_squads = [
|
||||
squad_uuid for squad_uuid in current_squads if squad_uuid != source_uuid
|
||||
]
|
||||
if not had_target_before:
|
||||
new_squads.append(target_uuid)
|
||||
|
||||
if subscription.user and subscription.user.remnawave_uuid:
|
||||
if api is None:
|
||||
panel_failed += 1
|
||||
logger.error(
|
||||
"❌ RemnaWave API недоступен для обновления пользователя %s",
|
||||
subscription.user.telegram_id,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=subscription.user.remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
)
|
||||
panel_updated += 1
|
||||
except Exception as error:
|
||||
panel_failed += 1
|
||||
logger.error(
|
||||
"❌ Ошибка обновления сквадов пользователя %s: %s",
|
||||
subscription.user.telegram_id,
|
||||
error,
|
||||
)
|
||||
continue
|
||||
|
||||
subscription.connected_squads = new_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
source_decrement += 1
|
||||
if not had_target_before:
|
||||
target_increment += 1
|
||||
|
||||
updated_subscriptions += 1
|
||||
|
||||
link_result = await db.execute(
|
||||
select(SubscriptionServer)
|
||||
.where(
|
||||
and_(
|
||||
SubscriptionServer.subscription_id == subscription.id,
|
||||
SubscriptionServer.server_squad_id == source_server.id,
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
link = link_result.scalars().first()
|
||||
|
||||
if link:
|
||||
if had_target_before:
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
and_(
|
||||
SubscriptionServer.subscription_id
|
||||
== subscription.id,
|
||||
SubscriptionServer.server_squad_id
|
||||
== source_server.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
link.server_squad_id = target_server.id
|
||||
elif not had_target_before:
|
||||
db.add(
|
||||
SubscriptionServer(
|
||||
subscription_id=subscription.id,
|
||||
server_squad_id=target_server.id,
|
||||
paid_price_kopeks=0,
|
||||
)
|
||||
)
|
||||
|
||||
if updated_subscriptions:
|
||||
if source_decrement:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == source_server.id)
|
||||
.values(
|
||||
current_users=func.greatest(
|
||||
ServerSquad.current_users - source_decrement,
|
||||
0,
|
||||
)
|
||||
)
|
||||
)
|
||||
if target_increment:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == target_server.id)
|
||||
.values(
|
||||
current_users=ServerSquad.current_users + target_increment
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
else:
|
||||
await db.rollback()
|
||||
|
||||
logger.info(
|
||||
"🚚 Завершен переезд сквада %s → %s: обновлено %s подписок (%s не обновлены в панели)",
|
||||
source_uuid,
|
||||
target_uuid,
|
||||
updated_subscriptions,
|
||||
panel_failed,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total": total_candidates,
|
||||
"updated": updated_subscriptions,
|
||||
"panel_updated": panel_updated,
|
||||
"panel_failed": panel_failed,
|
||||
"source_removed": source_decrement,
|
||||
"target_added": target_increment,
|
||||
}
|
||||
|
||||
except RemnaWaveConfigurationError:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as error:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"❌ Ошибка переезда сквада %s → %s: %s",
|
||||
source_uuid,
|
||||
target_uuid,
|
||||
error,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": "unexpected",
|
||||
"message": str(error),
|
||||
}
|
||||
finally:
|
||||
await exit_stack.aclose()
|
||||
|
||||
async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]:
|
||||
try:
|
||||
stats = {"created": 0, "updated": 0, "errors": 0, "deleted": 0}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,7 @@ class BotConfigurationService:
|
||||
"WEBHOOK": "🌐 Webhook",
|
||||
"LOG": "📝 Логирование",
|
||||
"DEBUG": "🧪 Режим разработки",
|
||||
"MODERATION": "🛡️ Модерация и фильтры",
|
||||
}
|
||||
|
||||
CATEGORY_DESCRIPTIONS: Dict[str, str] = {
|
||||
@@ -160,6 +161,7 @@ class BotConfigurationService:
|
||||
"WEBHOOK": "Пути и секреты вебхуков.",
|
||||
"LOG": "Уровни логирования и ротация.",
|
||||
"DEBUG": "Отладочные функции и безопасный режим.",
|
||||
"MODERATION": "Настройки фильтров отображаемых имен и защиты от фишинга.",
|
||||
}
|
||||
|
||||
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
|
||||
@@ -225,6 +227,7 @@ class BotConfigurationService:
|
||||
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
|
||||
"LOGO_FILE": "INTERFACE_BRANDING",
|
||||
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
|
||||
"MAIN_MENU_MODE": "INTERFACE",
|
||||
"CONNECT_BUTTON_MODE": "CONNECT_BUTTON",
|
||||
"MINIAPP_CUSTOM_URL": "CONNECT_BUTTON",
|
||||
"APP_CONFIG_PATH": "ADDITIONAL",
|
||||
@@ -280,6 +283,7 @@ class BotConfigurationService:
|
||||
"LOG_": "LOG",
|
||||
"WEB_API_": "WEB_API",
|
||||
"DEBUG": "DEBUG",
|
||||
"DISPLAY_NAME_": "MODERATION",
|
||||
}
|
||||
|
||||
CHOICES: Dict[str, List[ChoiceOption]] = {
|
||||
@@ -318,6 +322,10 @@ class BotConfigurationService:
|
||||
ChoiceOption("link", "🔗 Прямая ссылка"),
|
||||
ChoiceOption("happ_cryptolink", "🪙 Happ CryptoLink"),
|
||||
],
|
||||
"MAIN_MENU_MODE": [
|
||||
ChoiceOption("default", "📋 Полное меню"),
|
||||
ChoiceOption("text", "📝 Текстовое меню"),
|
||||
],
|
||||
"SERVER_STATUS_MODE": [
|
||||
ChoiceOption("disabled", "🚫 Отключено"),
|
||||
ChoiceOption("external_link", "🌐 Внешняя ссылка"),
|
||||
@@ -398,6 +406,13 @@ class BotConfigurationService:
|
||||
"warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.",
|
||||
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"DISPLAY_NAME_BANNED_KEYWORDS": {
|
||||
"description": "Список слов и фрагментов, при наличии которых в отображаемом имени пользователь будет заблокирован.",
|
||||
"format": "Перечислите ключевые слова через запятую или с новой строки.",
|
||||
"example": "support, security, служебн",
|
||||
"warning": "Слишком агрессивные фильтры могут блокировать добросовестных пользователей.",
|
||||
"dependencies": "Фильтр отображаемых имен",
|
||||
},
|
||||
"REMNAWAVE_API_URL": {
|
||||
"description": "Базовый адрес панели RemnaWave, с которой синхронизируется бот.",
|
||||
"format": "Полный URL вида https://panel.example.com.",
|
||||
|
||||
@@ -11,9 +11,10 @@ from app.database.models import Transaction, TransactionType, PaymentMethod
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction, get_transaction_by_external_id, complete_transaction
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -120,32 +121,52 @@ class TributeService:
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Tribute: {amount_kopeks/100}₽ (ID: {payment_id})"
|
||||
)
|
||||
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
|
||||
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
|
||||
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(session, user.id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения Tribute: {e}")
|
||||
|
||||
if not user.has_made_first_topup:
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await session.commit()
|
||||
|
||||
await session.refresh(user)
|
||||
|
||||
logger.info(
|
||||
f"✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})"
|
||||
)
|
||||
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
|
||||
|
||||
if was_first_topup:
|
||||
logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}")
|
||||
|
||||
|
||||
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
session, user, transaction, old_balance
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
||||
|
||||
@@ -166,6 +166,13 @@ class SquadCreateStates(StatesGroup):
|
||||
class SquadRenameStates(StatesGroup):
|
||||
waiting_for_new_name = State()
|
||||
|
||||
|
||||
class SquadMigrationStates(StatesGroup):
|
||||
selecting_source = State()
|
||||
selecting_target = State()
|
||||
confirming = State()
|
||||
|
||||
|
||||
class AdminSubmenuStates(StatesGroup):
|
||||
in_users_submenu = State()
|
||||
in_promo_submenu = State()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
DEFAULT_UNAVAILABLE_CALLBACK = "menu_profile_unavailable"
|
||||
|
||||
|
||||
def build_miniapp_or_callback_button(
|
||||
text: str,
|
||||
*,
|
||||
callback_data: str,
|
||||
unavailable_callback: str = DEFAULT_UNAVAILABLE_CALLBACK,
|
||||
) -> InlineKeyboardButton:
|
||||
"""Create a button that opens the miniapp in text menu mode.
|
||||
|
||||
When the simplified text menu mode is enabled we should avoid exposing
|
||||
deep bot flows and redirect the user to the configured miniapp instead.
|
||||
If the miniapp URL is missing we fall back to a safe callback that shows
|
||||
an alert about the unavailable profile rather than opening disabled
|
||||
sections of the bot.
|
||||
"""
|
||||
|
||||
if settings.is_text_main_menu_mode():
|
||||
miniapp_url = settings.get_main_menu_miniapp_url()
|
||||
if miniapp_url:
|
||||
return InlineKeyboardButton(
|
||||
text=text,
|
||||
web_app=types.WebAppInfo(url=miniapp_url),
|
||||
)
|
||||
safe_callback = unavailable_callback or DEFAULT_UNAVAILABLE_CALLBACK
|
||||
return InlineKeyboardButton(text=text, callback_data=safe_callback)
|
||||
|
||||
return InlineKeyboardButton(text=text, callback_data=callback_data)
|
||||
@@ -69,10 +69,12 @@ async def edit_or_answer_photo(
|
||||
caption: str,
|
||||
keyboard: types.InlineKeyboardMarkup,
|
||||
parse_mode: str | None = "HTML",
|
||||
*,
|
||||
force_text: bool = False,
|
||||
) -> None:
|
||||
resolved_parse_mode = parse_mode or "HTML"
|
||||
# Если режим логотипа выключен — работаем текстом
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
# Если режим логотипа выключен или требуется текстовое сообщение — работаем текстом
|
||||
if force_text or not settings.ENABLE_LOGO_MODE:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
@@ -84,7 +86,10 @@ async def edit_or_answer_photo(
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
except TelegramBadRequest as error:
|
||||
await callback.message.delete()
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
|
||||
return
|
||||
|
||||
|
||||
@@ -12,6 +12,25 @@ from app.database.models import User, ReferralEarning, Transaction, TransactionT
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_referrer_info(user: User) -> str:
|
||||
"""Return formatted referrer info for admin notifications."""
|
||||
|
||||
referred_by_id = getattr(user, "referred_by_id", None)
|
||||
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
referrer = getattr(user, "referrer", None)
|
||||
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
|
||||
return f"ID {referrer.telegram_id}"
|
||||
|
||||
|
||||
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
|
||||
max_attempts = 10
|
||||
|
||||
|
||||
+3267
-46
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,11 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.server_squad import (
|
||||
count_active_users_for_squad,
|
||||
get_server_squad_by_uuid,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.remnawave import (
|
||||
RemnaWaveConnectionStatus,
|
||||
@@ -22,6 +27,10 @@ from ..schemas.remnawave import (
|
||||
RemnaWaveSquadActionRequest,
|
||||
RemnaWaveSquadCreateRequest,
|
||||
RemnaWaveSquadListResponse,
|
||||
RemnaWaveSquadMigrationPreviewResponse,
|
||||
RemnaWaveSquadMigrationRequest,
|
||||
RemnaWaveSquadMigrationResponse,
|
||||
RemnaWaveSquadMigrationStats,
|
||||
RemnaWaveSquadUpdateRequest,
|
||||
RemnaWaveStatusResponse,
|
||||
RemnaWaveSystemStatsResponse,
|
||||
@@ -372,6 +381,30 @@ async def get_user_traffic(
|
||||
return RemnaWaveUserTrafficResponse(telegram_id=telegram_id, **stats)
|
||||
|
||||
|
||||
@router.get("/squads/{squad_uuid}/migration-preview", response_model=RemnaWaveSquadMigrationPreviewResponse)
|
||||
async def preview_squad_migration(
|
||||
squad_uuid: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveSquadMigrationPreviewResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
squad = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if not squad:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден")
|
||||
|
||||
users_to_migrate = await count_active_users_for_squad(db, squad_uuid)
|
||||
|
||||
return RemnaWaveSquadMigrationPreviewResponse(
|
||||
squad_uuid=squad.squad_uuid,
|
||||
squad_name=squad.display_name,
|
||||
current_users=squad.current_users or 0,
|
||||
max_users=squad.max_users,
|
||||
users_to_migrate=users_to_migrate,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync/from-panel", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def sync_from_panel(
|
||||
payload: RemnaWaveSyncFromPanelRequest,
|
||||
@@ -454,3 +487,58 @@ async def get_sync_recommendations(
|
||||
data = await service.get_sync_recommendations(db)
|
||||
detail = "Рекомендации получены"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=data)
|
||||
|
||||
|
||||
@router.post("/squads/migrate", response_model=RemnaWaveSquadMigrationResponse)
|
||||
async def migrate_squad(
|
||||
payload: RemnaWaveSquadMigrationRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveSquadMigrationResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
source_uuid = payload.source_uuid.strip()
|
||||
target_uuid = payload.target_uuid.strip()
|
||||
|
||||
if source_uuid == target_uuid:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Источник и назначение совпадают")
|
||||
|
||||
source = await get_server_squad_by_uuid(db, source_uuid)
|
||||
if not source:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-источник не найден")
|
||||
|
||||
target = await get_server_squad_by_uuid(db, target_uuid)
|
||||
if not target:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-назначение не найден")
|
||||
|
||||
try:
|
||||
result = await service.migrate_squad_users(
|
||||
db,
|
||||
source_uuid=source.squad_uuid,
|
||||
target_uuid=target.squad_uuid,
|
||||
)
|
||||
except RemnaWaveConfigurationError as exc: # pragma: no cover - зависит от окружения
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
||||
|
||||
if not result.get("success"):
|
||||
detail = result.get("message") or "Не удалось выполнить переезд"
|
||||
return RemnaWaveSquadMigrationResponse(
|
||||
success=False,
|
||||
detail=detail,
|
||||
error=result.get("error"),
|
||||
)
|
||||
|
||||
stats = RemnaWaveSquadMigrationStats(
|
||||
source_uuid=source.squad_uuid,
|
||||
target_uuid=target.squad_uuid,
|
||||
total=result.get("total", 0),
|
||||
updated=result.get("updated", 0),
|
||||
panel_updated=result.get("panel_updated", 0),
|
||||
panel_failed=result.get("panel_failed", 0),
|
||||
source_removed=result.get("source_removed", 0),
|
||||
target_added=result.get("target_added", 0),
|
||||
)
|
||||
|
||||
detail = result.get("message") or "Переезд выполнен"
|
||||
return RemnaWaveSquadMigrationResponse(success=True, detail=detail, data=stats)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
|
||||
|
||||
class MiniAppBranding(BaseModel):
|
||||
@@ -134,6 +134,108 @@ class MiniAppPromoOfferClaimResponse(BaseModel):
|
||||
code: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionAutopay(BaseModel):
|
||||
enabled: bool = False
|
||||
autopay_enabled: Optional[bool] = None
|
||||
autopay_enabled_at: Optional[datetime] = None
|
||||
days_before: Optional[int] = None
|
||||
autopay_days_before: Optional[int] = None
|
||||
default_days_before: Optional[int] = None
|
||||
autopay_days_options: List[int] = Field(default_factory=list)
|
||||
days_options: List[int] = Field(default_factory=list)
|
||||
options: List[int] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class MiniAppSubscriptionRenewalPeriod(BaseModel):
|
||||
id: str
|
||||
days: Optional[int] = None
|
||||
months: Optional[int] = None
|
||||
price_kopeks: Optional[int] = Field(default=None, alias="priceKopeks")
|
||||
price_label: Optional[str] = Field(default=None, alias="priceLabel")
|
||||
original_price_kopeks: Optional[int] = Field(default=None, alias="originalPriceKopeks")
|
||||
original_price_label: Optional[str] = Field(default=None, alias="originalPriceLabel")
|
||||
discount_percent: int = Field(default=0, alias="discountPercent")
|
||||
price_per_month_kopeks: Optional[int] = Field(default=None, alias="pricePerMonthKopeks")
|
||||
price_per_month_label: Optional[str] = Field(default=None, alias="pricePerMonthLabel")
|
||||
is_recommended: bool = Field(default=False, alias="isRecommended")
|
||||
description: Optional[str] = None
|
||||
badge: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionRenewalOptionsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionRenewalOptionsResponse(BaseModel):
|
||||
success: bool = True
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
currency: str
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
promo_group: Optional[MiniAppPromoGroup] = Field(default=None, alias="promoGroup")
|
||||
promo_offer: Optional[Dict[str, Any]] = Field(default=None, alias="promoOffer")
|
||||
periods: List[MiniAppSubscriptionRenewalPeriod] = Field(default_factory=list)
|
||||
default_period_id: Optional[str] = Field(default=None, alias="defaultPeriodId")
|
||||
missing_amount_kopeks: Optional[int] = Field(default=None, alias="missingAmountKopeks")
|
||||
status_message: Optional[str] = Field(default=None, alias="statusMessage")
|
||||
autopay_enabled: bool = False
|
||||
autopay_days_before: Optional[int] = None
|
||||
autopay_days_options: List[int] = Field(default_factory=list)
|
||||
autopay: Optional[MiniAppSubscriptionAutopay] = None
|
||||
autopay_settings: Optional[MiniAppSubscriptionAutopay] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class MiniAppSubscriptionRenewalRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
period_id: Optional[str] = Field(default=None, alias="periodId")
|
||||
period_days: Optional[int] = Field(default=None, alias="periodDays")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionRenewalResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
renewed_until: Optional[datetime] = Field(default=None, alias="renewedUntil")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionAutopayRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
enabled: Optional[bool] = None
|
||||
days_before: Optional[int] = Field(default=None, alias="daysBefore")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionAutopayResponse(BaseModel):
|
||||
success: bool = True
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
autopay_enabled: bool = False
|
||||
autopay_days_before: Optional[int] = None
|
||||
autopay_days_options: List[int] = Field(default_factory=list)
|
||||
autopay: Optional[MiniAppSubscriptionAutopay] = None
|
||||
autopay_settings: Optional[MiniAppSubscriptionAutopay] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class MiniAppPromoCode(BaseModel):
|
||||
code: str
|
||||
type: Optional[str] = None
|
||||
@@ -253,9 +355,75 @@ class MiniAppReferralInfo(BaseModel):
|
||||
referrals: Optional[MiniAppReferralList] = None
|
||||
|
||||
|
||||
class MiniAppPaymentMethodsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
|
||||
class MiniAppPaymentMethod(BaseModel):
|
||||
id: str
|
||||
icon: Optional[str] = None
|
||||
requires_amount: bool = False
|
||||
currency: str = "RUB"
|
||||
min_amount_kopeks: Optional[int] = None
|
||||
max_amount_kopeks: Optional[int] = None
|
||||
amount_step_kopeks: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppPaymentMethodsResponse(BaseModel):
|
||||
methods: List[MiniAppPaymentMethod] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppPaymentCreateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
method: str
|
||||
amount_rubles: Optional[float] = Field(default=None, alias="amountRubles")
|
||||
amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks")
|
||||
payment_option: Optional[str] = Field(default=None, alias="option")
|
||||
|
||||
|
||||
class MiniAppPaymentCreateResponse(BaseModel):
|
||||
success: bool = True
|
||||
method: str
|
||||
payment_url: Optional[str] = None
|
||||
amount_kopeks: Optional[int] = None
|
||||
extra: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MiniAppPaymentStatusQuery(BaseModel):
|
||||
method: str
|
||||
local_payment_id: Optional[int] = Field(default=None, alias="localPaymentId")
|
||||
invoice_id: Optional[str] = Field(default=None, alias="invoiceId")
|
||||
payment_id: Optional[str] = Field(default=None, alias="paymentId")
|
||||
payload: Optional[str] = None
|
||||
amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks")
|
||||
started_at: Optional[str] = Field(default=None, alias="startedAt")
|
||||
|
||||
|
||||
class MiniAppPaymentStatusRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
payments: List[MiniAppPaymentStatusQuery] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppPaymentStatusResult(BaseModel):
|
||||
method: str
|
||||
status: str
|
||||
is_paid: bool = False
|
||||
amount_kopeks: Optional[int] = None
|
||||
currency: Optional[str] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
transaction_id: Optional[int] = None
|
||||
external_id: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
extra: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MiniAppPaymentStatusResponse(BaseModel):
|
||||
results: List[MiniAppPaymentStatusResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppSubscriptionResponse(BaseModel):
|
||||
success: bool = True
|
||||
subscription_id: int
|
||||
subscription_id: Optional[int] = None
|
||||
remnawave_short_uuid: Optional[str] = None
|
||||
user: MiniAppSubscriptionUser
|
||||
subscription_url: Optional[str] = None
|
||||
@@ -271,6 +439,7 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
happ_cryptolink_redirect_link: Optional[str] = None
|
||||
happ_cryptolink_redirect_template: Optional[str] = None
|
||||
balance_kopeks: int = 0
|
||||
balance_rubles: float = 0.0
|
||||
balance_currency: Optional[str] = None
|
||||
@@ -283,8 +452,290 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
total_spent_label: Optional[str] = None
|
||||
subscription_type: str
|
||||
autopay_enabled: bool = False
|
||||
autopay_days_before: Optional[int] = None
|
||||
autopay_days_options: List[int] = Field(default_factory=list)
|
||||
autopay: Optional[MiniAppSubscriptionAutopay] = None
|
||||
autopay_settings: Optional[MiniAppSubscriptionAutopay] = None
|
||||
branding: Optional[MiniAppBranding] = None
|
||||
faq: Optional[MiniAppFaq] = None
|
||||
legal_documents: Optional[MiniAppLegalDocuments] = None
|
||||
referral: Optional[MiniAppReferralInfo] = None
|
||||
subscription_missing: bool = False
|
||||
subscription_missing_reason: Optional[str] = None
|
||||
trial_available: bool = False
|
||||
trial_duration_days: Optional[int] = None
|
||||
trial_status: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class MiniAppSubscriptionServerOption(BaseModel):
|
||||
uuid: str
|
||||
name: Optional[str] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
discount_percent: Optional[int] = None
|
||||
is_connected: bool = False
|
||||
is_available: bool = True
|
||||
disabled_reason: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrafficOption(BaseModel):
|
||||
value: Optional[int] = None
|
||||
label: Optional[str] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
is_current: bool = False
|
||||
is_available: bool = True
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionDeviceOption(BaseModel):
|
||||
value: int
|
||||
label: Optional[str] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionCurrentSettings(BaseModel):
|
||||
servers: List[MiniAppConnectedServer] = Field(default_factory=list)
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
traffic_limit_label: Optional[str] = None
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class MiniAppSubscriptionServersSettings(BaseModel):
|
||||
available: List[MiniAppSubscriptionServerOption] = Field(default_factory=list)
|
||||
min: int = 0
|
||||
max: int = 0
|
||||
can_update: bool = True
|
||||
hint: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrafficSettings(BaseModel):
|
||||
options: List[MiniAppSubscriptionTrafficOption] = Field(default_factory=list)
|
||||
can_update: bool = True
|
||||
current_value: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionDevicesSettings(BaseModel):
|
||||
options: List[MiniAppSubscriptionDeviceOption] = Field(default_factory=list)
|
||||
can_update: bool = True
|
||||
min: int = 0
|
||||
max: int = 0
|
||||
step: int = 1
|
||||
current: int = 0
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionBillingContext(BaseModel):
|
||||
months_remaining: int = 1
|
||||
period_hint_days: Optional[int] = None
|
||||
renews_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettings(BaseModel):
|
||||
subscription_id: int
|
||||
currency: str = "RUB"
|
||||
current: MiniAppSubscriptionCurrentSettings
|
||||
servers: MiniAppSubscriptionServersSettings
|
||||
traffic: MiniAppSubscriptionTrafficSettings
|
||||
devices: MiniAppSubscriptionDevicesSettings
|
||||
billing: Optional[MiniAppSubscriptionBillingContext] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsResponse(BaseModel):
|
||||
success: bool = True
|
||||
settings: MiniAppSubscriptionSettings
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _populate_aliases(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
if "subscriptionId" in values and "subscription_id" not in values:
|
||||
values["subscription_id"] = values["subscriptionId"]
|
||||
return values
|
||||
|
||||
|
||||
class MiniAppSubscriptionServersUpdateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = None
|
||||
servers: Optional[List[str]] = None
|
||||
squads: Optional[List[str]] = None
|
||||
server_uuids: Optional[List[str]] = None
|
||||
squad_uuids: Optional[List[str]] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _populate_aliases(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
alias_map = {
|
||||
"subscriptionId": "subscription_id",
|
||||
"serverUuids": "server_uuids",
|
||||
"squadUuids": "squad_uuids",
|
||||
}
|
||||
for alias, target in alias_map.items():
|
||||
if alias in values and target not in values:
|
||||
values[target] = values[alias]
|
||||
return values
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrafficUpdateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = None
|
||||
traffic: Optional[int] = None
|
||||
traffic_gb: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _populate_aliases(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
alias_map = {
|
||||
"subscriptionId": "subscription_id",
|
||||
"trafficGb": "traffic_gb",
|
||||
}
|
||||
for alias, target in alias_map.items():
|
||||
if alias in values and target not in values:
|
||||
values[target] = values[alias]
|
||||
return values
|
||||
|
||||
|
||||
class MiniAppSubscriptionDevicesUpdateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = None
|
||||
devices: Optional[int] = None
|
||||
device_limit: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _populate_aliases(cls, values: Any) -> Any:
|
||||
if isinstance(values, dict):
|
||||
alias_map = {
|
||||
"subscriptionId": "subscription_id",
|
||||
"deviceLimit": "device_limit",
|
||||
}
|
||||
for alias, target in alias_map.items():
|
||||
if alias in values and target not in values:
|
||||
values[target] = values[alias]
|
||||
return values
|
||||
|
||||
|
||||
class MiniAppSubscriptionUpdateResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseOptionsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseOptionsResponse(BaseModel):
|
||||
success: bool = True
|
||||
currency: str
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
data: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchasePreviewRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
selection: Optional[Dict[str, Any]] = None
|
||||
period_id: Optional[str] = Field(default=None, alias="periodId")
|
||||
period_days: Optional[int] = Field(default=None, alias="periodDays")
|
||||
period: Optional[str] = None
|
||||
traffic_value: Optional[int] = Field(default=None, alias="trafficValue")
|
||||
traffic: Optional[int] = None
|
||||
traffic_gb: Optional[int] = Field(default=None, alias="trafficGb")
|
||||
servers: Optional[List[str]] = None
|
||||
countries: Optional[List[str]] = None
|
||||
server_uuids: Optional[List[str]] = Field(default=None, alias="serverUuids")
|
||||
devices: Optional[int] = None
|
||||
device_limit: Optional[int] = Field(default=None, alias="deviceLimit")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _merge_selection(cls, values: Any) -> Any:
|
||||
if not isinstance(values, dict):
|
||||
return values
|
||||
selection = values.get("selection")
|
||||
if isinstance(selection, dict):
|
||||
merged = {**selection, **values}
|
||||
else:
|
||||
merged = dict(values)
|
||||
aliases = {
|
||||
"period_id": ("periodId", "period", "code"),
|
||||
"period_days": ("periodDays",),
|
||||
"traffic_value": ("trafficValue", "traffic", "trafficGb"),
|
||||
"servers": ("countries", "server_uuids", "serverUuids"),
|
||||
"devices": ("deviceLimit",),
|
||||
}
|
||||
for target, sources in aliases.items():
|
||||
if merged.get(target) is not None:
|
||||
continue
|
||||
for source in sources:
|
||||
if source in merged and merged[source] is not None:
|
||||
merged[target] = merged[source]
|
||||
break
|
||||
return merged
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchasePreviewResponse(BaseModel):
|
||||
success: bool = True
|
||||
preview: Dict[str, Any] = Field(default_factory=dict)
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseRequest(MiniAppSubscriptionPurchasePreviewRequest):
|
||||
pass
|
||||
|
||||
|
||||
class MiniAppSubscriptionPurchaseResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrialRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrialResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
trial_status: Optional[str] = Field(default=None, alias="trialStatus")
|
||||
trial_duration_days: Optional[int] = Field(default=None, alias="trialDurationDays")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
@@ -169,3 +169,34 @@ class RemnaWaveGenericSyncResponse(BaseModel):
|
||||
success: bool
|
||||
detail: Optional[str] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationPreviewResponse(BaseModel):
|
||||
squad_uuid: str
|
||||
squad_name: str
|
||||
current_users: int
|
||||
max_users: Optional[int] = None
|
||||
users_to_migrate: int
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationRequest(BaseModel):
|
||||
source_uuid: str
|
||||
target_uuid: str
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationStats(BaseModel):
|
||||
source_uuid: str
|
||||
target_uuid: str
|
||||
total: int = 0
|
||||
updated: int = 0
|
||||
panel_updated: int = 0
|
||||
panel_failed: int = 0
|
||||
source_removed: int = 0
|
||||
target_added: int = 0
|
||||
|
||||
|
||||
class RemnaWaveSquadMigrationResponse(BaseModel):
|
||||
success: bool
|
||||
detail: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
data: Optional[RemnaWaveSquadMigrationStats] = None
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Градиенты для щита -->
|
||||
<linearGradient id="shieldGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#4a90e2;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#357abd;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#2c5aa0;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Градиент для свечения -->
|
||||
<radialGradient id="glowGradient">
|
||||
<stop offset="0%" style="stop-color:#00d4ff;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#00d4ff;stop-opacity:0" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Анимация мерцания -->
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
<animate attributeName="stdDeviation"
|
||||
values="2;4;2"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"/>
|
||||
</filter>
|
||||
|
||||
<!-- Анимация электрических разрядов -->
|
||||
<animate id="pulse" attributeName="opacity"
|
||||
values="0.3;1;0.3"
|
||||
dur="1.5s"
|
||||
repeatCount="indefinite"/>
|
||||
</defs>
|
||||
|
||||
<!-- Фоновое свечение -->
|
||||
<circle cx="100" cy="100" r="80" fill="url(#glowGradient)" opacity="0.4">
|
||||
<animate attributeName="r"
|
||||
values="75;85;75"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Основной щит -->
|
||||
<path d="M 100 30
|
||||
C 100 30, 140 35, 140 35
|
||||
C 140 35, 145 80, 145 100
|
||||
C 145 120, 140 140, 130 155
|
||||
C 120 170, 100 180, 100 180
|
||||
C 100 180, 80 170, 70 155
|
||||
C 60 140, 55 120, 55 100
|
||||
C 55 80, 60 35, 60 35
|
||||
C 60 35, 100 30, 100 30 Z"
|
||||
fill="url(#shieldGradient)"
|
||||
stroke="#00d4ff"
|
||||
stroke-width="2"
|
||||
filter="url(#glow)"/>
|
||||
|
||||
<!-- Электрические разряды по краям -->
|
||||
<path d="M 100 30 L 105 40 L 100 45 L 110 55"
|
||||
stroke="#00ffff"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
opacity="0.7">
|
||||
<animate attributeName="opacity"
|
||||
values="0.3;1;0.3;1;0.3"
|
||||
dur="1.5s"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
|
||||
<path d="M 140 50 L 135 55 L 140 60 L 132 70"
|
||||
stroke="#00ffff"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
opacity="0.7">
|
||||
<animate attributeName="opacity"
|
||||
values="1;0.3;1;0.3;1"
|
||||
dur="1.5s"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
|
||||
<path d="M 60 50 L 65 55 L 60 60 L 68 70"
|
||||
stroke="#00ffff"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
opacity="0.7">
|
||||
<animate attributeName="opacity"
|
||||
values="0.5;1;0.5;1;0.5"
|
||||
dur="1.5s"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
|
||||
<!-- Внутреннее свечение -->
|
||||
<path d="M 100 45
|
||||
C 100 45, 130 48, 130 48
|
||||
C 130 48, 133 85, 133 100
|
||||
C 133 115, 130 135, 122 147
|
||||
C 114 159, 100 167, 100 167
|
||||
C 100 167, 86 159, 78 147
|
||||
C 70 135, 67 115, 67 100
|
||||
C 67 85, 70 48, 70 48
|
||||
C 70 48, 100 45, 100 45 Z"
|
||||
fill="none"
|
||||
stroke="#00d4ff"
|
||||
stroke-width="1"
|
||||
opacity="0.5">
|
||||
<animate attributeName="opacity"
|
||||
values="0.3;0.7;0.3"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
|
||||
<!-- Текст VPN -->
|
||||
<text x="100" y="105"
|
||||
font-family="Arial, sans-serif"
|
||||
font-size="28"
|
||||
font-weight="bold"
|
||||
fill="#ffffff"
|
||||
text-anchor="middle"
|
||||
stroke="#00d4ff"
|
||||
stroke-width="0.5">
|
||||
VPN
|
||||
<animate attributeName="fill-opacity"
|
||||
values="0.8;1;0.8"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"/>
|
||||
</text>
|
||||
|
||||
<!-- Энергетические частицы -->
|
||||
<circle cx="85" cy="80" r="2" fill="#00ffff" opacity="0.8">
|
||||
<animate attributeName="cy"
|
||||
values="80;120;80"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity"
|
||||
values="0;0.8;0"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<circle cx="115" cy="90" r="2" fill="#00ffff" opacity="0.8">
|
||||
<animate attributeName="cy"
|
||||
values="90;130;90"
|
||||
dur="2.5s"
|
||||
repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity"
|
||||
values="0;0.8;0"
|
||||
dur="2.5s"
|
||||
repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<circle cx="100" cy="70" r="2" fill="#00ffff" opacity="0.8">
|
||||
<animate attributeName="cy"
|
||||
values="70;110;70"
|
||||
dur="2.8s"
|
||||
repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity"
|
||||
values="0;0.8;0"
|
||||
dur="2.8s"
|
||||
repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
+52
-1
@@ -38,6 +38,7 @@
|
||||
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_APPLE_TV": "📺 Apple TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
@@ -391,6 +392,19 @@
|
||||
"SUBSCRIPTION_NONE": "❌ No active subscription",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_PERIOD": "Period discount: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_TRAFFIC": "Traffic discount: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_SERVERS": "Servers discount: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_DEVICES": "Devices discount: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_PROMO": "Promo offer: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_DISCOUNT_NOTE": "Discount: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_BASE": "Base plan",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC": "Traffic",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_SERVERS": "Servers",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_DEVICES": "Devices",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_PROMO": "Promo discount",
|
||||
"MINIAPP_PURCHASE_SUMMARY_DISCOUNT": "You save {amount}",
|
||||
"MINIAPP_PURCHASE_STATUS_INSUFFICIENT": "Not enough funds on balance",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Final configuration</b>\n\n📅 <b>Period:</b> {period} days\n📈 <b>Traffic:</b> {traffic}\n🌍 <b>Countries:</b> {countries}\n📱 <b>Devices:</b> {devices}\n\n💰 <b>Total:</b> {total_price}\n\nConfirm the purchase?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Trial subscription",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Technical support</b>\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n",
|
||||
@@ -462,12 +476,14 @@
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_OTHER_APPS": "📦 <b>Other apps:</b> {app_list}",
|
||||
"SUBSCRIPTION_DEVICE_OTHER_APPS_HINT": "Tap the \"Other apps\" button below to choose another app.",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Step 2 - Add subscription:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Step 3 - Connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>How to connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Tap the \"Connect\" button below",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Apps for {device_name}</b>",
|
||||
@@ -870,7 +886,42 @@
|
||||
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Manage nodes",
|
||||
"ADMIN_REMNAWAVE_SYNC": "🔄 Synchronization",
|
||||
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Manage squads",
|
||||
"ADMIN_REMNAWAVE_MIGRATION": "🚚 Migration",
|
||||
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Traffic",
|
||||
"ADMIN_SQUAD_MIGRATION_TITLE": "🚚 <b>Squad migration</b>",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE": "Choose the squad you want to migrate from:",
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS": "No squads available. Add new ones or cancel the operation.",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE": "✅ Available",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE": "🚫 Unavailable",
|
||||
"ADMIN_SQUAD_MIGRATION_SERVER_LABEL": "{name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON": "🌍 {name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT": "✅",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT": "🚫",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE": "Page {page}/{pages}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE": "Source: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET": "Choose the destination squad:",
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY": "No other squads available. Cancel or create new squads.",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND": "Squad not found or unavailable.",
|
||||
"ADMIN_SQUAD_MIGRATION_SAME_SQUAD": "You can't choose the same squad.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS": "Review the migration parameters:",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE": "• From: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET": "• To: {target}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT": "• Users to migrate: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT": "Confirm the operation.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON": "✅ Confirm",
|
||||
"ADMIN_SQUAD_MIGRATION_CHANGE_TARGET": "🔄 Change destination",
|
||||
"ADMIN_SQUAD_MIGRATION_IN_PROGRESS": "Starting migration...",
|
||||
"ADMIN_SQUAD_MIGRATION_API_ERROR": "❌ Remnawave API is not configured: {error}",
|
||||
"ADMIN_SQUAD_MIGRATION_ERROR": "❌ Failed to migrate (code: {code}). {details}",
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON": "🔁 New migration",
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON": "⬅️ Back to Remnawave",
|
||||
"ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE": "✅ Migration completed",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_TOTAL": "Subscriptions matched: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_UPDATED": "Migrated: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED": "Updated in panel: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED": "Panel update failed: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CANCELLED": "❌ Migration cancelled.",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE_HINT": "This is the current page.",
|
||||
"ADMIN_STATS_USERS": "👥 Users",
|
||||
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_STATS_REVENUE": "💰 Revenue",
|
||||
|
||||
+52
-1
@@ -215,6 +215,7 @@
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_APPLE_TV": "📺 Apple TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
@@ -364,6 +365,19 @@
|
||||
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_PERIOD": "Скидка на период: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_TRAFFIC": "Скидка на трафик: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_SERVERS": "Скидка на серверы: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_DEVICES": "Скидка на устройства: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_DISCOUNT_PROMO": "Промо-предложение: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_DISCOUNT_NOTE": "Скидка: -{amount} ({percent}%)",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_BASE": "Базовый план",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC": "Трафик",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_SERVERS": "Серверы",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_DEVICES": "Устройства",
|
||||
"MINIAPP_PURCHASE_BREAKDOWN_PROMO": "Промо скидка",
|
||||
"MINIAPP_PURCHASE_SUMMARY_DISCOUNT": "Экономия {amount}",
|
||||
"MINIAPP_PURCHASE_STATUS_INSUFFICIENT": "Недостаточно средств на балансе",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
|
||||
@@ -464,12 +478,14 @@
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_OTHER_APPS": "📦 <b>Другие приложения:</b> {app_list}",
|
||||
"SUBSCRIPTION_DEVICE_OTHER_APPS_HINT": "Нажмите кнопку \"Другие приложения\" ниже, чтобы выбрать приложение.",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Шаг 2 - Добавление подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Шаг 3 - Подключение:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>Как подключить:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Нажмите кнопку \"Подключиться\" ниже",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Приложения для {device_name}</b>",
|
||||
@@ -872,7 +888,42 @@
|
||||
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Управление нодами",
|
||||
"ADMIN_REMNAWAVE_SYNC": "🔄 Синхронизация",
|
||||
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Управление сквадами",
|
||||
"ADMIN_REMNAWAVE_MIGRATION": "🚚 Переезд",
|
||||
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Трафик",
|
||||
"ADMIN_SQUAD_MIGRATION_TITLE": "🚚 <b>Переезд сквадов</b>",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_SOURCE": "Выберите сквад, из которого нужно переехать:",
|
||||
"ADMIN_SQUAD_MIGRATION_NO_OPTIONS": "Нет доступных сквадов. Добавьте новые или отмените операцию.",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE": "✅ Доступен",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE": "🚫 Недоступен",
|
||||
"ADMIN_SQUAD_MIGRATION_SERVER_LABEL": "{name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON": "🌍 {name} — 👥 {users} ({status})",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT": "✅",
|
||||
"ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT": "🚫",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE": "Стр. {page}/{pages}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE": "Источник: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_SELECT_TARGET": "Выберите сквад, в который нужно переехать:",
|
||||
"ADMIN_SQUAD_MIGRATION_TARGET_EMPTY": "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
|
||||
"ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND": "Сквад не найден или недоступен.",
|
||||
"ADMIN_SQUAD_MIGRATION_SAME_SQUAD": "Нельзя выбрать тот же сквад.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS": "Проверьте параметры переезда:",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE": "• Из: {source}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET": "• В: {target}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT": "• Пользователей к переносу: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT": "Подтвердите выполнение операции.",
|
||||
"ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON": "✅ Подтвердить",
|
||||
"ADMIN_SQUAD_MIGRATION_CHANGE_TARGET": "🔄 Изменить сервер назначения",
|
||||
"ADMIN_SQUAD_MIGRATION_IN_PROGRESS": "Запускаю переезд...",
|
||||
"ADMIN_SQUAD_MIGRATION_API_ERROR": "❌ Remnawave API не настроен: {error}",
|
||||
"ADMIN_SQUAD_MIGRATION_ERROR": "❌ Не удалось выполнить переезд (код: {code}). {details}",
|
||||
"ADMIN_SQUAD_MIGRATION_NEW_BUTTON": "🔁 Новый переезд",
|
||||
"ADMIN_SQUAD_MIGRATION_BACK_BUTTON": "⬅️ В Remnawave",
|
||||
"ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE": "✅ Переезд завершен",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_TOTAL": "Найдено подписок: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_UPDATED": "Перенесено: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED": "Обновлено в панели: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED": "Не удалось обновить в панели: {count}",
|
||||
"ADMIN_SQUAD_MIGRATION_CANCELLED": "❌ Переезд отменен.",
|
||||
"ADMIN_SQUAD_MIGRATION_PAGE_HINT": "Это текущая страница.",
|
||||
"ADMIN_STATS_USERS": "👥 Пользователи",
|
||||
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_STATS_REVENUE": "💰 Доходы",
|
||||
|
||||
+486
-61
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"config": {},
|
||||
"config": {
|
||||
"additionalLocales": [
|
||||
"ru",
|
||||
"zh",
|
||||
"fa"
|
||||
],
|
||||
"branding": {
|
||||
"name": "Subscription",
|
||||
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
|
||||
"supportUrl": "https://t.me"
|
||||
}
|
||||
},
|
||||
"platforms": {
|
||||
"ios": [
|
||||
{
|
||||
@@ -12,19 +23,102 @@
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"fa": "App Store [EU]",
|
||||
"ru": "App Store [EU]",
|
||||
"zh": "App Store [EU]"
|
||||
"en": "Open in App Store [EU]",
|
||||
"fa": "باز کردن در App Store [EU]",
|
||||
"ru": "Открыть в App Store [EU]",
|
||||
"zh": "在 App Store 中打开 [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"fa": "App Store [RU]",
|
||||
"ru": "App Store [RU]",
|
||||
"zh": "App Store [RU]"
|
||||
"en": "Open in App Store [RU]",
|
||||
"fa": "باز کردن در App Store [RU]",
|
||||
"ru": "Открыть в App Store [RU]",
|
||||
"zh": "在 App Store 中打开 [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "streisand",
|
||||
"name": "Streisand",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "streisand://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shadowrocket",
|
||||
"name": "Shadowrocket",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "sub://",
|
||||
"isNeedBase64Encoding": true,
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -64,10 +158,10 @@
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"fa": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Google Play"
|
||||
"en": "Open in Google Play",
|
||||
"fa": "باز کردن در Google Play",
|
||||
"ru": "Открыть в Google Play",
|
||||
"zh": "在 Google Play 中打开"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -103,53 +197,210 @@
|
||||
"zh": "打开应用并连接到服务器"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"macos": [
|
||||
},
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"id": "clash-meta",
|
||||
"name": "Clash Meta",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"fa": "App Store [EU]",
|
||||
"ru": "App Store [EU]",
|
||||
"zh": "App Store [EU]"
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"fa": "App Store [RU]",
|
||||
"ru": "App Store [RU]",
|
||||
"zh": "App Store [RU]"
|
||||
"en": "Open in F-Droid",
|
||||
"fa": "در F-Droid باز کنید",
|
||||
"ru": "Открыть в F-Droid",
|
||||
"zh": "在 F-Droid 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
"en": "Download and install Clash Meta APK",
|
||||
"fa": "دانلود و نصب Clash Meta APK",
|
||||
"ru": "Скачайте и установите Clash Meta APK",
|
||||
"zh": "下载并安装 Clash Meta APK"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
"en": "Tap the button to import configuration",
|
||||
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
|
||||
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
|
||||
"zh": "点击按钮导入配置"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"en": "Open Clash Meta and tap on Connect",
|
||||
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
|
||||
"ru": "Откройте Clash Meta и нажмите Подключиться",
|
||||
"zh": "打开 Clash Meta 并点击连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"macos": [
|
||||
{
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
@@ -159,75 +410,249 @@
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "happ://add/",
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Download",
|
||||
"ru": "Скачать"
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Download and install Happ.",
|
||||
"ru": "Скачайте и установите Happ"
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server",
|
||||
"ru": "Выберете локацию, включить VPN"
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linux": [],
|
||||
"androidTV": [
|
||||
{
|
||||
"id": "vpn4tv",
|
||||
"name": "VPN4TV",
|
||||
"id": "new-app-androidtv-1760203310792",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "",
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
"ru": "Button TextGoogle Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение"
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linux": [],
|
||||
"appleTV": []
|
||||
"appleTV": [
|
||||
{
|
||||
"id": "new-app-appletv-1760203488851",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11834
-190
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,353 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
BACKUP_DIR = ROOT_DIR / 'data' / 'backups'
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
os.environ.setdefault('BOT_TOKEN', 'test-token')
|
||||
|
||||
from app.config import settings
|
||||
from app.webapi.routes import miniapp
|
||||
from app.database.models import PaymentMethod
|
||||
from app.webapi.schemas.miniapp import (
|
||||
MiniAppPaymentCreateRequest,
|
||||
MiniAppPaymentMethodsRequest,
|
||||
MiniAppPaymentStatusQuery,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return 'asyncio'
|
||||
|
||||
|
||||
def test_compute_cryptobot_limits_scale_with_rate():
|
||||
low_rate_min, low_rate_max = miniapp._compute_cryptobot_limits(70.0)
|
||||
high_rate_min, high_rate_max = miniapp._compute_cryptobot_limits(120.0)
|
||||
|
||||
assert low_rate_min == 7000
|
||||
assert low_rate_max == 7000000
|
||||
assert high_rate_min == 12000
|
||||
assert high_rate_max == 12000000
|
||||
assert high_rate_min > low_rate_min
|
||||
assert high_rate_max > low_rate_max
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_payment_link_pal24_uses_selected_option(monkeypatch):
|
||||
monkeypatch.setattr(settings, 'PAL24_ENABLED', True, raising=False)
|
||||
monkeypatch.setattr(settings, 'PAL24_API_TOKEN', 'token', raising=False)
|
||||
monkeypatch.setattr(settings, 'PAL24_SHOP_ID', 'shop', raising=False)
|
||||
monkeypatch.setattr(settings, 'PAL24_MIN_AMOUNT_KOPEKS', 1000, raising=False)
|
||||
monkeypatch.setattr(settings, 'PAL24_MAX_AMOUNT_KOPEKS', 5000000, raising=False)
|
||||
|
||||
captured_calls = []
|
||||
|
||||
class DummyPaymentService:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def create_pal24_payment(self, db, **kwargs):
|
||||
captured_calls.append({'db': db, **kwargs})
|
||||
return {
|
||||
'local_payment_id': 101,
|
||||
'bill_id': 'BILL42',
|
||||
'order_id': 'ORD42',
|
||||
'payment_method': kwargs.get('payment_method'),
|
||||
'sbp_url': 'https://sbp',
|
||||
'card_url': 'https://card',
|
||||
'link_url': 'https://link',
|
||||
}
|
||||
|
||||
async def fake_resolve_user(db, init_data):
|
||||
return types.SimpleNamespace(id=123, language='ru'), {}
|
||||
|
||||
monkeypatch.setattr(miniapp, 'PaymentService', lambda *args, **kwargs: DummyPaymentService())
|
||||
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
|
||||
|
||||
payload = MiniAppPaymentCreateRequest(
|
||||
initData='test',
|
||||
method='pal24',
|
||||
amountKopeks=15000,
|
||||
option='card',
|
||||
)
|
||||
|
||||
response = await miniapp.create_payment_link(payload, db=types.SimpleNamespace())
|
||||
|
||||
assert response.payment_url == 'https://card'
|
||||
assert response.extra['selected_option'] == 'card'
|
||||
assert response.extra['payment_method'] == 'CARD'
|
||||
assert captured_calls and captured_calls[0]['payment_method'] == 'CARD'
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_resolve_yookassa_status_includes_identifiers(monkeypatch):
|
||||
payment = types.SimpleNamespace(
|
||||
id=55,
|
||||
user_id=1,
|
||||
amount_kopeks=15000,
|
||||
currency='RUB',
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
captured_at=None,
|
||||
updated_at=None,
|
||||
created_at=datetime.utcnow(),
|
||||
transaction_id=42,
|
||||
yookassa_payment_id='yk_1',
|
||||
)
|
||||
|
||||
async def fake_get_by_local_id(db, local_id):
|
||||
return payment if local_id == 55 else None
|
||||
|
||||
async def fake_get_by_id(db, payment_id):
|
||||
return None
|
||||
|
||||
stub_module = types.SimpleNamespace(
|
||||
get_yookassa_payment_by_local_id=fake_get_by_local_id,
|
||||
get_yookassa_payment_by_id=fake_get_by_id,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, 'app.database.crud.yookassa', stub_module)
|
||||
|
||||
user = types.SimpleNamespace(id=1)
|
||||
query = MiniAppPaymentStatusQuery(
|
||||
method='yookassa',
|
||||
localPaymentId=55,
|
||||
paymentId='yk_1',
|
||||
amountKopeks=15000,
|
||||
startedAt='2024-01-01T00:00:00Z',
|
||||
payload='payload123',
|
||||
)
|
||||
|
||||
result = await miniapp._resolve_yookassa_payment_status(db=None, user=user, query=query)
|
||||
|
||||
assert result.extra['local_payment_id'] == 55
|
||||
assert result.extra['payment_id'] == 'yk_1'
|
||||
assert result.extra['invoice_id'] == 'yk_1'
|
||||
assert result.extra['payload'] == 'payload123'
|
||||
assert result.extra['started_at'] == '2024-01-01T00:00:00Z'
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_resolve_pal24_status_includes_identifiers(monkeypatch):
|
||||
async def fake_get_pal24_payment_by_bill_id(db, bill_id):
|
||||
return None
|
||||
|
||||
stub_module = types.SimpleNamespace(
|
||||
get_pal24_payment_by_bill_id=fake_get_pal24_payment_by_bill_id,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, 'app.database.crud.pal24', stub_module)
|
||||
|
||||
paid_at = datetime.utcnow()
|
||||
|
||||
payment = types.SimpleNamespace(
|
||||
id=321,
|
||||
user_id=1,
|
||||
amount_kopeks=25000,
|
||||
currency='RUB',
|
||||
is_paid=True,
|
||||
status='PAID',
|
||||
paid_at=paid_at,
|
||||
updated_at=paid_at,
|
||||
created_at=paid_at - timedelta(minutes=1),
|
||||
transaction_id=777,
|
||||
bill_id='BILL99',
|
||||
order_id='ORD99',
|
||||
payment_method='SBP',
|
||||
)
|
||||
|
||||
class StubPal24Service:
|
||||
async def get_pal24_payment_status(self, db, local_id):
|
||||
assert local_id == 321
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'PAID',
|
||||
'remote_status': 'PAID',
|
||||
}
|
||||
|
||||
user = types.SimpleNamespace(id=1)
|
||||
query = MiniAppPaymentStatusQuery(
|
||||
method='pal24',
|
||||
localPaymentId=321,
|
||||
amountKopeks=25000,
|
||||
startedAt='2024-01-01T00:00:00Z',
|
||||
payload='pal24_payload',
|
||||
)
|
||||
|
||||
result = await miniapp._resolve_pal24_payment_status(
|
||||
StubPal24Service(),
|
||||
db=None,
|
||||
user=user,
|
||||
query=query,
|
||||
)
|
||||
|
||||
assert result.status == 'paid'
|
||||
assert result.extra['local_payment_id'] == 321
|
||||
assert result.extra['bill_id'] == 'BILL99'
|
||||
assert result.extra['order_id'] == 'ORD99'
|
||||
assert result.extra['payment_method'] == 'SBP'
|
||||
assert result.extra['payload'] == 'pal24_payload'
|
||||
assert result.extra['started_at'] == '2024-01-01T00:00:00Z'
|
||||
assert result.extra['remote_status'] == 'PAID'
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_payment_link_stars_normalizes_amount(monkeypatch):
|
||||
monkeypatch.setattr(settings, 'TELEGRAM_STARS_ENABLED', True, raising=False)
|
||||
monkeypatch.setattr(settings, 'TELEGRAM_STARS_RATE_RUB', 1000.0, raising=False)
|
||||
monkeypatch.setattr(settings, 'BOT_TOKEN', 'test-token', raising=False)
|
||||
|
||||
captured = {}
|
||||
|
||||
class DummyPaymentService:
|
||||
def __init__(self, bot):
|
||||
captured['bot'] = bot
|
||||
|
||||
async def create_stars_invoice(
|
||||
self,
|
||||
amount_kopeks,
|
||||
description,
|
||||
payload,
|
||||
*,
|
||||
stars_amount=None,
|
||||
):
|
||||
captured['amount_kopeks'] = amount_kopeks
|
||||
captured['description'] = description
|
||||
captured['payload'] = payload
|
||||
captured['stars_amount'] = stars_amount
|
||||
return 'https://invoice.example'
|
||||
|
||||
class DummySession:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
captured['session_closed'] = True
|
||||
|
||||
class DummyBot:
|
||||
def __init__(self, token):
|
||||
captured['bot_token'] = token
|
||||
self.session = DummySession()
|
||||
|
||||
async def fake_resolve_user(db, init_data):
|
||||
return types.SimpleNamespace(id=7, language='ru'), {}
|
||||
|
||||
monkeypatch.setattr(miniapp, 'PaymentService', lambda bot: DummyPaymentService(bot))
|
||||
monkeypatch.setattr(miniapp, 'Bot', DummyBot)
|
||||
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
|
||||
|
||||
payload = MiniAppPaymentCreateRequest(
|
||||
initData='data',
|
||||
method='stars',
|
||||
amountKopeks=101000,
|
||||
)
|
||||
|
||||
response = await miniapp.create_payment_link(payload, db=types.SimpleNamespace())
|
||||
|
||||
assert response.payment_url == 'https://invoice.example'
|
||||
assert response.amount_kopeks == 100000
|
||||
assert response.extra['stars_amount'] == 1
|
||||
assert response.extra['requested_amount_kopeks'] == 101000
|
||||
assert captured['amount_kopeks'] == 100000
|
||||
assert captured['stars_amount'] == 1
|
||||
assert captured['bot_token'] == 'test-token'
|
||||
assert captured.get('session_closed') is True
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_get_payment_methods_exposes_stars_min_amount(monkeypatch):
|
||||
monkeypatch.setattr(settings, 'TELEGRAM_STARS_ENABLED', True, raising=False)
|
||||
monkeypatch.setattr(settings, 'TELEGRAM_STARS_RATE_RUB', 999.99, raising=False)
|
||||
|
||||
async def fake_resolve_user(db, init_data):
|
||||
return types.SimpleNamespace(id=1, language='ru'), {}
|
||||
|
||||
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
|
||||
|
||||
payload = MiniAppPaymentMethodsRequest(initData='abc')
|
||||
|
||||
response = await miniapp.get_payment_methods(payload, db=types.SimpleNamespace())
|
||||
|
||||
stars_method = next((method for method in response.methods if method.id == 'stars'), None)
|
||||
assert stars_method is not None
|
||||
assert stars_method.min_amount_kopeks == 99999
|
||||
assert stars_method.amount_step_kopeks == 99999
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_find_recent_deposit_ignores_transactions_before_attempt():
|
||||
started_at = datetime(2024, 5, 1, 12, 0, 0)
|
||||
|
||||
transaction = types.SimpleNamespace(
|
||||
id=10,
|
||||
amount_kopeks=1000,
|
||||
completed_at=None,
|
||||
created_at=started_at - timedelta(minutes=1),
|
||||
)
|
||||
|
||||
class DummyResult:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
class DummySession:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
async def execute(self, query): # noqa: ARG002
|
||||
return DummyResult(self._value)
|
||||
|
||||
result = await miniapp._find_recent_deposit(
|
||||
DummySession(transaction),
|
||||
user_id=1,
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
amount_kopeks=1000,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_find_recent_deposit_accepts_recent_transactions():
|
||||
started_at = datetime(2024, 5, 1, 12, 0, 0)
|
||||
|
||||
transaction = types.SimpleNamespace(
|
||||
id=11,
|
||||
amount_kopeks=1000,
|
||||
completed_at=started_at + timedelta(seconds=5),
|
||||
created_at=started_at + timedelta(seconds=5),
|
||||
)
|
||||
|
||||
class DummyResult:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
class DummySession:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
async def execute(self, query): # noqa: ARG002
|
||||
return DummyResult(self._value)
|
||||
|
||||
result = await miniapp._find_recent_deposit(
|
||||
DummySession(transaction),
|
||||
user_id=1,
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
amount_kopeks=1000,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
assert result is transaction
|
||||
Reference in New Issue
Block a user