Compare commits
528 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2fc7581a3 | |||
| e96010d24d | |||
| 35fc39b9f2 | |||
| 46ea2de300 | |||
| 2152ded3c4 | |||
| da0e8a2e3e | |||
| da540e8680 | |||
| a12ad52c7e | |||
| 38b5fa297b | |||
| d50475974c | |||
| ada891f2d9 | |||
| e7467e5434 | |||
| fa4a692574 | |||
| 9c60ef2c33 | |||
| 634633d12e | |||
| b3fd0c50ca | |||
| acb267668e | |||
| 344cffef60 | |||
| ef652d4518 | |||
| a66f9bbf28 | |||
| 3e5fbcf2bd | |||
| ba2de9e150 | |||
| b638e04629 | |||
| 45a5f012c2 | |||
| 1a66094800 | |||
| f7afe481bb | |||
| f04bf43962 | |||
| 3964ed1f3c | |||
| 98fa8eef5d | |||
| c7b47eef4d | |||
| 9270bb3bf5 | |||
| ad2f6f6f22 | |||
| 62749f9ffe | |||
| e623f9df3a | |||
| 2b79de7d82 | |||
| cdcc3b4c4a | |||
| e757569628 | |||
| 4f88cdf7db | |||
| 406c13aea2 | |||
| c95b1e4fad | |||
| 43c480fa33 | |||
| cb25bbb2ed | |||
| 2602a05fb2 | |||
| c31da6c2ae | |||
| 17d51ab286 | |||
| e576cb97ec | |||
| 217fed9c9c | |||
| 030f4efbe1 | |||
| 33b3828abc | |||
| f2876fd02c | |||
| ee9ba71f3c | |||
| 6a5d5d960f | |||
| 910409cdfb | |||
| d78b36b60e | |||
| ca2f83db88 | |||
| bf591f55ea | |||
| 99aa09349e | |||
| a391b635ed | |||
| 4f1ff34dce | |||
| 9a23a305b5 | |||
| 539e7e9066 | |||
| 6b1173a1b5 | |||
| 23f8e158ad | |||
| 578061be8c | |||
| 84816b84e7 | |||
| 0ad2f5bb8a | |||
| e415b4b2ee | |||
| 7d6ecaaa1b | |||
| 588461c53a | |||
| 25c23bd653 | |||
| 1a033de259 | |||
| 850f8d2b2e | |||
| 39374095eb | |||
| f4fe8913fa | |||
| 9a5203770b | |||
| 64b0f5161c | |||
| 8e12609746 | |||
| 703d09c556 | |||
| 7b238a32b4 | |||
| c72fa990fa | |||
| f8fc929cbf | |||
| 744aaeb5d6 | |||
| 515088ff45 | |||
| 96b64df989 | |||
| f51c22c67e | |||
| 010c412268 | |||
| 7d204684e1 | |||
| 08f47694ee | |||
| e61977a368 | |||
| 72ae83e6ef | |||
| 80aa0c2443 | |||
| 9e0eb3fd4a | |||
| 6fe4bad240 | |||
| 859089e91d | |||
| c4fa25321e | |||
| 96a6f26b1e | |||
| 3f0d125207 | |||
| cb5821fffc | |||
| f79420a9b0 | |||
| a7b4dc985f | |||
| 38562744ab | |||
| 3d413ced59 | |||
| 0002ce0a9a | |||
| 140d3580e9 | |||
| cf5cb406d0 | |||
| 8c3f3d56c8 | |||
| d33831dfe4 | |||
| b68f1256ca | |||
| 3d3b019e60 | |||
| f1a801d693 | |||
| de33e15ae0 | |||
| abd87c2a48 | |||
| f691e91560 | |||
| 7dafb0c29f | |||
| 5037d92e36 | |||
| d765e08017 | |||
| 4958613ad9 | |||
| 489667c847 | |||
| ccedfad51f | |||
| 841ac5cdb3 | |||
| a8b6202fe9 | |||
| 4708c67dcc | |||
| f705b508a1 | |||
| 6030fb08ec | |||
| a5b152e73a | |||
| 087a57fc11 | |||
| 95e44f99a2 | |||
| b8bb2acd64 | |||
| eb3e806e0f | |||
| 33911aca93 | |||
| 63c6e64cbe | |||
| 358c8aed3b | |||
| 4ac994454b | |||
| 5d6f080663 | |||
| 33f0e3b9ba | |||
| ef809de9a2 | |||
| 628c53513c | |||
| 876362cd80 | |||
| 1a9efd65a1 | |||
| 121918c33a | |||
| b46c3bd142 | |||
| 566b563626 | |||
| 397af1cc86 | |||
| bdfa2a3039 | |||
| 38001410b8 | |||
| c2693fc46a | |||
| 9db84af7a9 | |||
| 0172138084 | |||
| 0e913ec78b | |||
| 6a5d0181b0 | |||
| 7684ed382e | |||
| 0d47acf044 | |||
| 2d09b10f36 | |||
| 77c99ad33e | |||
| a41531944c | |||
| 9b2d9406d3 | |||
| 1776a5b424 | |||
| 419b921487 | |||
| c1f7404912 | |||
| 1c07f40d43 | |||
| d2e6a42eb8 | |||
| 5a7960bb14 | |||
| 27d48f68b4 | |||
| 9465c6cba3 | |||
| 3088be1dde | |||
| 845a8175fa | |||
| af27fb570c | |||
| 181bb137fb | |||
| 220646c7ac | |||
| 4513deb2a1 | |||
| e1001156b4 | |||
| ba2c534622 | |||
| 72f06a784e | |||
| 04e862b6bd | |||
| 7f4af89d60 | |||
| f68bb699eb | |||
| c1d19e2ade | |||
| e4c7e8b27f | |||
| c63c483b9b | |||
| 4b04696feb | |||
| 9ffbba7259 | |||
| cc0b927388 | |||
| cbcf2d55da | |||
| f7c70f60e1 | |||
| 7c96b0f71c | |||
| 2002032be3 | |||
| ed6ff293ea | |||
| 65e66353b2 | |||
| 4fa87804d9 | |||
| 17587a9b30 | |||
| 37c552c705 | |||
| 95c6e72f1b | |||
| 4fa49e6f2e | |||
| 3aaa3d0671 | |||
| 9c013f19c4 | |||
| c7b6929dec | |||
| 4dca932ec2 | |||
| 997fcf8ec7 | |||
| eb8c8ec4ce | |||
| f4ed61c4ca | |||
| 70d3434952 | |||
| 450d75227b | |||
| 6e61980fa9 | |||
| 777e243bcd | |||
| 46d9c25968 | |||
| e26223a7d8 | |||
| 2098165229 | |||
| c7e9d84585 | |||
| 8a92d5e5a9 | |||
| 8b8f187cb3 | |||
| 02e3faf194 | |||
| 7e8e05a0fc | |||
| c3ca12a8a5 | |||
| d82b24927b | |||
| 7d9d05b1e8 | |||
| cc96919672 | |||
| 1f0f5a4317 | |||
| cd9218ec09 | |||
| 7256a0d1ab | |||
| e2f5191844 | |||
| d964a2d53c | |||
| 7ace06834b | |||
| 0a789d3bd1 | |||
| 9c44c3ebd0 | |||
| 67650666fc | |||
| b5e343cfb0 | |||
| 68f72a88a4 | |||
| a225540c48 | |||
| 3fdc1fdeda | |||
| 0748e9eed3 | |||
| f420e07a24 | |||
| 885a56ecf4 | |||
| 4e4e80f3c4 | |||
| 4942ac4c5e | |||
| 20f83ac751 | |||
| 6b2b20cca2 | |||
| 9db392b410 | |||
| 444e87d70e | |||
| d8a6b6b08c | |||
| 2658c298f0 | |||
| 0ca4f9630a | |||
| 718c9aba92 | |||
| 078f1f8878 | |||
| c76eeea020 | |||
| f919368d0b | |||
| ccb442fe44 | |||
| d8c0dd862b | |||
| 1ed418b176 | |||
| 59efd837cd | |||
| 60d23bedde | |||
| d08f74179f | |||
| 70ad084bcd | |||
| 818ef1ccb0 | |||
| f6369f7b44 | |||
| 5f8295a588 | |||
| 5c51788dc3 | |||
| 24e6d57273 | |||
| be95d34122 | |||
| 359cdd106d | |||
| 0bc5b10a78 | |||
| efd4eda62a | |||
| d4c169e6bb | |||
| c12f3d443c | |||
| db7ecea006 | |||
| 2b4136faa7 | |||
| ca09ce8cf4 | |||
| 533087b333 | |||
| 80afcbb737 | |||
| 845faf037f | |||
| 1f160ced02 | |||
| 07e4c07d6e | |||
| 78ab84b021 | |||
| db5551df23 | |||
| ee75395c31 | |||
| 453bb75b7f | |||
| 88d2c8325d | |||
| 8fe9a3dbe2 | |||
| cf6c282b25 | |||
| 773e525a6a | |||
| a7d35d51b8 | |||
| 46eb93dabe | |||
| 43e63f864e | |||
| 8774b53570 | |||
| 2aaec2ca58 | |||
| 5efe36878d | |||
| b9861249b7 | |||
| 2c9b585f4b | |||
| dd5b36ff4a | |||
| 144a942a97 | |||
| ff661d84ee | |||
| ab1b8619f8 | |||
| 0f621bfe5a | |||
| c344f418c5 | |||
| 400cc5a32e | |||
| b6a383b5d8 | |||
| a8215ddec5 | |||
| 954c96ed16 | |||
| ed9a3613f5 | |||
| 29743351de | |||
| fd897464f5 | |||
| d9b0420202 | |||
| 527d907035 | |||
| 6c3270e6d7 | |||
| b7382eb7c3 | |||
| 0cd7d45338 | |||
| 23dcb9699c | |||
| 9603952b2a | |||
| 299eeee9d3 | |||
| 63c7e9c08b | |||
| 42131457bf | |||
| a7370fbe52 | |||
| 41f0a15cea | |||
| dd92f37fc1 | |||
| 46d4d699dd | |||
| 76b594363b | |||
| 107b18d642 | |||
| 9973491503 | |||
| 418081048e | |||
| f22838b99f | |||
| 9bdacea568 | |||
| 6bec0ed0c8 | |||
| 4363955443 | |||
| 47a272c7e8 | |||
| 07ce852917 | |||
| a6ada5c9e3 | |||
| 90c5b1d003 | |||
| 326aca31d7 | |||
| 4fd5ba863b | |||
| 4bd0b221e7 | |||
| f47511f26a | |||
| 2d409eaa1f | |||
| 9d42c4fb7d | |||
| d3ced8f7e2 | |||
| a5715b2ca8 | |||
| c18dd604d8 | |||
| c2656f4ee6 | |||
| 18641d46d3 | |||
| 7ef3a08215 | |||
| 55f1a3b220 | |||
| e2f313e6ea | |||
| 5f99478433 | |||
| 89f0a3663d | |||
| e53dab87e3 | |||
| 6d85c179bb | |||
| 30dafcef49 | |||
| 32d7a7394e | |||
| 3cb4f27433 | |||
| 50692566d5 | |||
| fbb2afd9e1 | |||
| 3de951b153 | |||
| 1b86d467b5 | |||
| e4743ced9b | |||
| 2351056d56 | |||
| 748f9a2393 | |||
| ada15065ea | |||
| 53f1831f59 | |||
| 860ebf18b6 | |||
| ae05ca08ea | |||
| 88e8a56526 | |||
| fb4d714441 | |||
| 2690f63a82 | |||
| ec9855a0a9 | |||
| 984ad8e448 | |||
| ca81b32502 | |||
| 70f190501b | |||
| 432efdbb7c | |||
| 83a821a591 | |||
| 14bdfd4a1e | |||
| 66ff2a38d3 | |||
| 9f7c9cad38 | |||
| 2132226b15 | |||
| 849465b2e1 | |||
| 402b31625b | |||
| 5fc24dbfae | |||
| 8ba65cc2fe | |||
| ee2bffe08e | |||
| c4b5db9002 | |||
| 1a78bdc194 | |||
| a3bb749a6a | |||
| 626f78b230 | |||
| 5d44285c0a | |||
| 2d8091ddcb | |||
| fa6a4698ab | |||
| f40454313f | |||
| 8a256b1bc2 | |||
| 71ac7ea464 | |||
| bbc9f93429 | |||
| 3ba9598b5e | |||
| ffa51986a8 | |||
| 7e20140a00 | |||
| 50d080f54e | |||
| 29829b1c8c | |||
| e84e707d2f | |||
| c12566ae53 | |||
| e6976b5262 | |||
| 81cfdcf7ac | |||
| 9c1ca5b748 | |||
| a0d14f97f4 | |||
| 8fdc70fdc3 | |||
| d93139a458 | |||
| b78dc12db9 | |||
| 2a02f589bf | |||
| bcfa4f8400 | |||
| 042dc900ef | |||
| 6004607bf6 | |||
| 40e4b1ce7f | |||
| a4c5a2295f | |||
| 3d9a75ff51 | |||
| 1216a4dd9d | |||
| 4bc1c402e9 | |||
| 42a758bd68 | |||
| 07a0d0ca43 | |||
| f53b04444b | |||
| 9877c00c92 | |||
| 14aa175193 | |||
| 562766a637 | |||
| 4616911189 | |||
| 1e14a0919f | |||
| e1f0bd152c | |||
| c10e34ad43 | |||
| 714f742560 | |||
| dc5e440fe2 | |||
| d53873dc7f | |||
| 10ab557d8d | |||
| 81147d42a9 | |||
| 8d5e2037d3 | |||
| 7f03e61b5f | |||
| 870ee2fb66 | |||
| a525079282 | |||
| 39aff2bc1b | |||
| b4dc9f1970 | |||
| 37ac3e7a81 | |||
| e3e6428d2a | |||
| 3e3385d1eb | |||
| 0375e74227 | |||
| b6bdeeefdd | |||
| 24b3660f7b | |||
| da2c30cd8d | |||
| 1fad95c0bb | |||
| 6a1b1a9a91 | |||
| 9abf0b6ebe | |||
| 34e90b335b | |||
| e84c82f29d | |||
| 1c929a1a70 | |||
| 1c8878e651 | |||
| 84645d84f7 | |||
| 0cc59501bc | |||
| ef6aaa58ea | |||
| 942ce48241 | |||
| 625ec43c34 | |||
| f184303415 | |||
| 84a17864cd | |||
| 1476247519 | |||
| 0ea95005ee | |||
| ba808f66da | |||
| e8472a502f | |||
| 7d552ab715 | |||
| 00de3a41df | |||
| 36f38187d9 | |||
| dd68b2b788 | |||
| 7a9d37c884 | |||
| 6a904d4be2 | |||
| b5b8408055 | |||
| 86a3228579 | |||
| 560993c0f3 | |||
| a3fe935149 | |||
| a75549aebf | |||
| be3fc7aa09 | |||
| 6cbfc6d74b | |||
| 7ff7a051d9 | |||
| 9a2f156158 | |||
| 38b058636c | |||
| 32523f5dea | |||
| ff70e38dbc | |||
| 8110f52bc3 | |||
| a5a0176433 | |||
| 2d960e3e30 | |||
| b5fa978023 | |||
| 9cc49e9f66 | |||
| dbf63e518f | |||
| 3e218b6e6d | |||
| 2f52f6b8de | |||
| 5d377cf885 | |||
| 62ecacc3cc | |||
| 4ab6d45e50 | |||
| d1bed5e41a | |||
| b907f884ba | |||
| 83a76c928d | |||
| 90d3f3aef2 | |||
| 263df606d2 | |||
| 2a38969e3d | |||
| 300feec18e | |||
| 7c56452eb4 | |||
| 548ec540f2 | |||
| bceba34cc3 | |||
| b07ce2be5c | |||
| 9472cf5e8c | |||
| 82f34e8c6e | |||
| 02b718dac5 | |||
| a65a23acb2 | |||
| 12523e7515 | |||
| d3497f924a | |||
| 75faa54b85 | |||
| 02a9ac9be5 | |||
| d22f9aaf62 | |||
| 68f5e2ab80 | |||
| 4e2b71cb85 | |||
| e229a7ee65 | |||
| abf1f53218 | |||
| 2859219f80 | |||
| e71b7f4533 | |||
| 5ba652fab6 | |||
| 0a3913a2aa | |||
| 118ef8a29f | |||
| b5f7e06605 | |||
| d258d27406 | |||
| 554fb28cdb | |||
| 808901c38b | |||
| 68508d8a86 | |||
| 0daf8d7691 | |||
| 5aeb2038c5 | |||
| d3a1060117 | |||
| 4d22e99076 | |||
| 23f53dc5df | |||
| b4d857eb88 | |||
| 51ba5b980c | |||
| 9fb6d7ecf6 | |||
| d297e9b6d1 |
@@ -89,8 +89,13 @@ MAX_DEVICES_LIMIT=15
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# Плюсовать дни триала при покупке платной подписке
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID=false
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
# Сбрасывать трафик при каждой оплате подписки
|
||||
RESET_TRAFFIC_ON_PAYMENT=false
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
@@ -285,6 +290,10 @@ CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
|
||||
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
|
||||
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
|
||||
|
||||
# Параметры режима happ_cryptolink
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
|
||||
@@ -338,6 +347,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.4.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.3-$(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.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.3-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.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.3-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.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.3-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.3-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.3-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!install_bot.sh
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
!docs/
|
||||
!docs/**
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.4.0"
|
||||
ARG VERSION="v2.4.3"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8081 8082 8083
|
||||
EXPOSE 8080 8081 8082
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
|
||||
@@ -71,7 +71,53 @@ eGames Cookies | Cookies в формате key:value | Для панелей eGa
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 🐳 Docker запуск
|
||||
### 🧙♂️ Автоустановка через `install_bot.sh`
|
||||
|
||||
Скрипт-установщик берёт на себя подготовку окружения, настройку конфигурации и дальнейшее обслуживание бота. Он работает поверх Docker Compose и требует заранее установить:
|
||||
|
||||
- **Docker Engine** и **Docker Compose plugin** (2.20+);
|
||||
- **Git** и **Bash** (по умолчанию есть в большинстве Linux дистрибутивов);
|
||||
- `openssl` (используется для генерации токенов, но не обязателен — при отсутствии скрипт использует `urandom`).
|
||||
|
||||
```bash
|
||||
# 1. Скачай репозиторий
|
||||
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
|
||||
# 2. Создай необходимые директории
|
||||
mkdir -p ./logs ./data ./data/backups ./data/referral_qr
|
||||
chmod -R 755 ./logs ./data
|
||||
sudo chown -R 1000:1000 ./logs ./data
|
||||
|
||||
# 3. Запусти мастер установки
|
||||
chmod +x install_bot.sh # один раз, если файл не исполняемый
|
||||
./install_bot.sh
|
||||
```
|
||||
|
||||
На первом запуске мастер:
|
||||
|
||||
1. Спросит путь установки и сохранит его в `./.bot_install_state` — можно оставлять путь по умолчанию (текущая директория).
|
||||
2. Поможет собрать `.env`: запросит обязательные токены (бот, Remnawave, админы), при необходимости сгенерирует Web API и PostgreSQL пароли, предложит авторизацию Basic Auth или eGames secret.
|
||||
3. Подготовит структуру каталогов (`logs`, `data`, `backups` и т. д.) и проверит, что Docker готов к запуску.
|
||||
4. Создаст (или обновит) `docker-compose.yml`, настроит внешнюю сеть `bot_network`, чтобы дальнейшие сервисы (например, Caddy) могли подключаться.
|
||||
5. Запустит контейнеры бота, PostgreSQL и Redis и выведет их статус.
|
||||
|
||||
После установки повторный запуск `./install_bot.sh` открывает **интерактивное меню управления**:
|
||||
|
||||
- 📊 Мониторинг состояния контейнеров и ресурсов.
|
||||
- ⚙️ Управление сервисами (запуск/остановка/пересборка).
|
||||
- 📋 Просмотр и поиск по логам.
|
||||
- 🔄 Обновление проекта из Git с автоматическим бэкапом.
|
||||
- 💾 Создание и 📦 восстановление резервных копий (включая базу данных).
|
||||
- 🧹 Очистка логов, бэкапов и образов.
|
||||
- 🌐 Помощник настройки обратного прокси Caddy (webhook + miniapp, обновление `docker-compose`, перезагрузка).
|
||||
- ⚙️ Конфигуратор `.env` (редактирование, пересоздание, маскировка секретов).
|
||||
|
||||
> 💡 Скрипт можно запускать сколько угодно раз — он хранит путь установки и понимает, когда конфигурация уже создана. Меню работает и по SSH (достаточно TTY), а для скриптов можно передать путь установки через stdin.
|
||||
|
||||
### 🐳 Ручной Docker запуск
|
||||
|
||||
Если не хочется пользоваться мастером, можно настроить всё вручную:
|
||||
|
||||
```bash
|
||||
# 1. Скачай репозиторий
|
||||
@@ -91,11 +137,269 @@ sudo chown -R 1000:1000 ./logs ./data
|
||||
docker compose up -d
|
||||
|
||||
# 5. Проверь статус
|
||||
docker compose logs
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Настройка обратного прокси и доменов
|
||||
|
||||
> Этот раздел описывает полноценную ручную настройку обратного прокси для **двух разных доменов**: отдельный домен для вебхуков
|
||||
> (`hooks.example.com`) и отдельный домен для мини-приложения (`miniapp.example.com`). Оба прокси-сервера (Caddy или nginx) дол
|
||||
> жны работать в одной Docker-сети с ботом, чтобы обращаться к сервису по внутреннему имени `remnawave_bot` без проброса порто
|
||||
> в наружу.
|
||||
|
||||
### 1. Планирование доменов и переменных окружения
|
||||
|
||||
1. Добавьте в DNS по **A/AAAA-записи** для обоих доменов на IP сервера, где запущен бот.
|
||||
2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол).
|
||||
3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами:
|
||||
```env
|
||||
WEBHOOK_URL=https://hooks.example.com/webhook
|
||||
WEB_API_ENABLED=true
|
||||
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
|
||||
MINIAPP_CUSTOM_URL=https://miniapp.example.com
|
||||
```
|
||||
При необходимости добавьте пути для дополнительных провайдеров (`TRIBUTE_WEBHOOK_PATH`, `YOOKASSA_WEBHOOK_PATH` и т. д.) —
|
||||
далее в конфигурациях мы их пробрасываем на соответствующие порты контейнера бота.
|
||||
|
||||
### 2. Общая Docker-сеть для бота и прокси
|
||||
|
||||
`docker-compose.yml` бота создаёт сеть `bot_network`. Чтобы внешний прокси видел сервис `remnawave_bot`, нужно:
|
||||
|
||||
```bash
|
||||
# Если бот уже запущен через docker compose up -d
|
||||
docker compose ps
|
||||
|
||||
# Убедиться, что сеть существует
|
||||
docker network ls | grep bot_network || docker network create bot_network
|
||||
|
||||
# Подключить прокси (если контейнер уже запущен отдельно)
|
||||
docker network connect bot_network <proxy_container_name>
|
||||
```
|
||||
|
||||
Если прокси запускается через **собственный docker-compose**, в файле нужно объявить ту же сеть как внешнюю:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
|
||||
Именно так поступает мастер `install_bot.sh`: при первичной установке он создаёт `bot_network` и при повторном запуске в меню №
|
||||
8 («🌐 Настройка обратного прокси (Caddy)») может автоматически подготовить `docker-compose` и `Caddyfile`, обновить сеть и пер
|
||||
езапустить сервис.
|
||||
|
||||
### 3. Ручная установка Caddy в Docker
|
||||
|
||||
1. Создайте каталог для конфигурации:
|
||||
```bash
|
||||
mkdir -p ~/caddy
|
||||
cd ~/caddy
|
||||
```
|
||||
2. Сохраните docker-compose-файл `docker-compose.caddy.yml`:
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: remnawave_caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
# Укажите путь к каталогу miniapp из репозитория бота
|
||||
- /root/remnawave-bedolaga-telegram-bot/miniapp:/miniapp:ro
|
||||
- /root/remnawave-bedolaga-telegram-bot/miniapp/redirect:/miniapp/redirect:ro
|
||||
networks:
|
||||
- bot_network
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
3. Создайте `Caddyfile` с двумя виртуальными хостами:
|
||||
```caddy
|
||||
webhook.domain.com {
|
||||
handle /tribute-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /cryptobot-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /mulenpay-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /pal24-webhook* {
|
||||
reverse_proxy localhost:8084
|
||||
}
|
||||
|
||||
handle /yookassa-webhook* {
|
||||
reverse_proxy localhost:8082
|
||||
}
|
||||
|
||||
handle /health {
|
||||
reverse_proxy localhost:8081/health
|
||||
}
|
||||
}
|
||||
|
||||
miniapp.domain.com {
|
||||
encode gzip zstd
|
||||
root * /var/www/remnawave-miniapp
|
||||
file_server
|
||||
|
||||
@config path /app-config.json
|
||||
header @config Access-Control-Allow-Origin "*"
|
||||
|
||||
# Redirect for /miniapp/redirect/index.html
|
||||
@redirect path /miniapp/redirect/index.html
|
||||
redir @redirect /miniapp/redirect/index.html permanent
|
||||
|
||||
reverse_proxy /miniapp/* 127.0.0.1:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
```
|
||||
- Каталог `miniapp` из репозитория должен быть смонтирован в контейнер Caddy по путям `/miniapp` и `/miniapp/redirect`,
|
||||
чтобы статические файлы мини-приложения и страницы редиректа отдавались без дополнительной сборки. Замените
|
||||
`/root/remnawave-bedolaga-telegram-bot` в примере на реальный путь до установленного репозитория.
|
||||
- Caddy автоматически выпустит TLS-сертификаты через ACME, если DNS настроен правильно.
|
||||
4. Запустите прокси и убедитесь, что он присоединился к сети бота:
|
||||
```bash
|
||||
docker compose -f docker-compose.caddy.yml up -d
|
||||
docker network inspect bot_network | grep remnawave_caddy
|
||||
```
|
||||
5. Проверьте журналы Caddy и бота, выполните тестовый запрос `curl https://hooks.example.com/webhook` (должен отвечать 405/200
|
||||
в зависимости от webhook).
|
||||
|
||||
### 4. Ручная настройка nginx в Docker
|
||||
|
||||
1. Создайте каталог `/opt/nginx-remnawave` и поместите туда `docker-compose.nginx.yml`:
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
container_name: remnawave_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certs:/etc/ssl/private:ro
|
||||
- ./miniapp:/var/www/remnawave-miniapp:ro
|
||||
networks:
|
||||
- bot_network
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
Сертификаты можно выпускать вручную (certbot) и складывать в `./certs` либо использовать внешний контейнер `certbot`.
|
||||
2. Пример `nginx.conf` с двумя серверными блоками:
|
||||
```nginx
|
||||
events {}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
upstream remnawave_bot_hooks {
|
||||
server remnawave_bot:8081;
|
||||
}
|
||||
|
||||
upstream remnawave_bot_yookassa {
|
||||
server remnawave_bot:8082;
|
||||
}
|
||||
|
||||
upstream remnawave_bot_api {
|
||||
server remnawave_bot:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name hooks.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
|
||||
|
||||
location = /webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /tribute-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /cryptobot-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /mulenpay-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /pal24-webhook { proxy_pass http://remnawave_bot:8084; }
|
||||
location /yookassa-webhook { proxy_pass http://remnawave_bot_yookassa; }
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name miniapp.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
|
||||
|
||||
root /var/www/remnawave-miniapp;
|
||||
index index.html;
|
||||
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
location /miniapp/ {
|
||||
proxy_pass http://remnawave_bot_api/miniapp/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-API-Key "КЛЮЧ-WEBAPI";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
3. Запустите прокси и убедитесь, что он в сети `bot_network`:
|
||||
```bash
|
||||
docker compose -f docker-compose.nginx.yml up -d
|
||||
docker network inspect bot_network | grep remnawave_nginx
|
||||
```
|
||||
4. После запуска перепроверьте, что `curl -I https://hooks.example.com/webhook` возвращает HTTP-ответ 405/200, а `curl -H "X-AP
|
||||
I-Key: ..." https://miniapp.example.com/miniapp/health` отвечает 200.
|
||||
|
||||
### 5. Чек-лист проверки
|
||||
|
||||
- [ ] DNS-записи указывают на сервер, сертификаты валидны.
|
||||
- [ ] Контейнер прокси присоединён к `bot_network` и видит сервис `remnawave_bot`.
|
||||
- [ ] `WEBHOOK_URL` и `MINIAPP_CUSTOM_URL` в `.env` указывают на HTTPS-домены.
|
||||
- [ ] В логах бота нет ошибок `connection refused` или `invalid host header` при обращении из прокси.
|
||||
- [ ] Триггеры оплаты (Tribute, YooKassa и др.) успешно получают HTTP 200 при тестовых запросах на свои вебхуки.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Конфигурация
|
||||
|
||||
### 🔧 Основные параметры
|
||||
@@ -108,6 +412,14 @@ docker compose logs
|
||||
| 🛡️ **REMNAWAVE_SECRET_KEY** | Ключ защиты панели | `secret_name:secret_value` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
|
||||
### 🌐 Интеграция веб-админки
|
||||
|
||||
Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md).
|
||||
|
||||
### 📱 Telegram Mini App с подпиской
|
||||
|
||||
Инструкция по развёртыванию мини-приложения, публикации статической страницы и настройке reverse-proxy доступна в [docs/miniapp-setup.md](docs/miniapp-setup.md).
|
||||
|
||||
### 📊 Статус серверов в главном меню
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
@@ -331,6 +643,8 @@ DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
# Сбрасывать трафик при каждой оплате подписки
|
||||
RESET_TRAFFIC_ON_PAYMENT=false
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
@@ -525,6 +839,10 @@ CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
|
||||
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
|
||||
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
|
||||
|
||||
# Параметры режима happ_cryptolink
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
|
||||
@@ -578,6 +896,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
|
||||
+216
-146
@@ -1,163 +1,233 @@
|
||||
{
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"ru": "App Store [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"ru": "App Store [RU]"
|
||||
"config": {},
|
||||
"platforms": {
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"fa": "App Store [EU]",
|
||||
"ru": "App Store [EU]",
|
||||
"zh": "App Store [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"fa": "App Store [RU]",
|
||||
"ru": "App Store [RU]",
|
||||
"zh": "App Store [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"ru": "Скачать APK"
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"fa": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Google Play"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
|
||||
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"fa": "برنامه را باز کنید و به سرور متصل شوید",
|
||||
"ru": "Откройте приложение и подключитесь к серверу",
|
||||
"zh": "打开应用并连接到服务器"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Откройте приложение и подключитесь к серверу"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pc": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
|
||||
"buttonText": {
|
||||
"en": "Mac Os",
|
||||
"ru": "Mac Os"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"ru": "Windows"
|
||||
],
|
||||
"macos": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"fa": "App Store [EU]",
|
||||
"ru": "App Store [EU]",
|
||||
"zh": "App Store [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"fa": "App Store [RU]",
|
||||
"ru": "App Store [RU]",
|
||||
"zh": "App Store [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server",
|
||||
"ru": "Выберете локацию, включить VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tv": [
|
||||
{
|
||||
"id": "vpn4tv",
|
||||
"name": "VPN4TV",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
|
||||
"buttonText": {
|
||||
"en": "Download",
|
||||
"ru": "Скачать"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Download and install Happ.",
|
||||
"ru": "Скачайте и установите Happ"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server",
|
||||
"ru": "Выберете локацию, включить VPN"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"androidTV": [
|
||||
{
|
||||
"id": "vpn4tv",
|
||||
"name": "VPN4TV",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linux": [],
|
||||
"appleTV": []
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,12 @@ from app.handlers.admin import (
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates,
|
||||
backup as admin_backup,
|
||||
system_logs as admin_system_logs,
|
||||
welcome_text as admin_welcome_text,
|
||||
tickets as admin_tickets,
|
||||
reports as admin_reports,
|
||||
bot_configuration as admin_bot_configuration,
|
||||
pricing as admin_pricing,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -139,10 +141,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_user_messages.register_handlers(dp)
|
||||
admin_updates.register_handlers(dp)
|
||||
admin_backup.register_handlers(dp)
|
||||
admin_system_logs.register_handlers(dp)
|
||||
admin_welcome_text.register_welcome_text_handlers(dp)
|
||||
admin_tickets.register_handlers(dp)
|
||||
admin_reports.register_handlers(dp)
|
||||
admin_bot_configuration.register_handlers(dp)
|
||||
admin_pricing.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+116
-13
@@ -38,7 +38,7 @@ class Settings(BaseSettings):
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
|
||||
DATABASE_URL: str = ""
|
||||
DATABASE_URL: Optional[str] = None
|
||||
|
||||
POSTGRES_HOST: str = "postgres"
|
||||
POSTGRES_PORT: int = 5432
|
||||
@@ -53,8 +53,8 @@ class Settings(BaseSettings):
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
REMNAWAVE_API_URL: str = ""
|
||||
REMNAWAVE_API_KEY: str = ""
|
||||
REMNAWAVE_API_URL: Optional[str] = None
|
||||
REMNAWAVE_API_KEY: Optional[str] = None
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
|
||||
REMNAWAVE_USERNAME: Optional[str] = None
|
||||
@@ -66,10 +66,12 @@ class Settings(BaseSettings):
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: str = ""
|
||||
TRIAL_SQUAD_UUID: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
@@ -206,9 +208,16 @@ class Settings(BaseSettings):
|
||||
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PAL24_REQUEST_TIMEOUT: int = 30
|
||||
PAL24_SBP_BUTTON_TEXT: Optional[str] = None
|
||||
PAL24_CARD_BUTTON_TEXT: Optional[str] = None
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
MINIAPP_PURCHASE_URL: str = ""
|
||||
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
|
||||
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection"
|
||||
MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение"
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None
|
||||
HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None
|
||||
@@ -224,6 +233,7 @@ class Settings(BaseSettings):
|
||||
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/bot.log"
|
||||
@@ -231,6 +241,19 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
|
||||
WEB_API_ENABLED: bool = False
|
||||
WEB_API_HOST: str = "0.0.0.0"
|
||||
WEB_API_PORT: int = 8080
|
||||
WEB_API_WORKERS: int = 1
|
||||
WEB_API_ALLOWED_ORIGINS: str = "*"
|
||||
WEB_API_DOCS_ENABLED: bool = False
|
||||
WEB_API_TITLE: str = "Remnawave Bot Admin API"
|
||||
WEB_API_VERSION: str = "1.0.0"
|
||||
WEB_API_DEFAULT_TOKEN: Optional[str] = None
|
||||
WEB_API_DEFAULT_TOKEN_NAME: str = "Bootstrap Token"
|
||||
WEB_API_TOKEN_HASH_ALGORITHM: str = "sha256"
|
||||
WEB_API_REQUEST_LOGGING: bool = True
|
||||
|
||||
APP_CONFIG_PATH: str = "app-config.json"
|
||||
ENABLE_DEEP_LINKS: bool = True
|
||||
@@ -383,6 +406,14 @@ class Settings(BaseSettings):
|
||||
"password": self.REMNAWAVE_PASSWORD,
|
||||
"auth_type": self.REMNAWAVE_AUTH_TYPE
|
||||
}
|
||||
|
||||
def get_pal24_sbp_button_text(self, fallback: str) -> str:
|
||||
value = (self.PAL24_SBP_BUTTON_TEXT or "").strip()
|
||||
return value or fallback
|
||||
|
||||
def get_pal24_card_button_text(self, fallback: str) -> str:
|
||||
value = (self.PAL24_CARD_BUTTON_TEXT or "").strip()
|
||||
return value or fallback
|
||||
|
||||
def get_remnawave_user_delete_mode(self) -> str:
|
||||
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
|
||||
@@ -437,7 +468,10 @@ class Settings(BaseSettings):
|
||||
return ["ru", "en"]
|
||||
except AttributeError:
|
||||
return ["ru", "en"]
|
||||
|
||||
|
||||
def is_language_selection_enabled(self) -> bool:
|
||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks // 100
|
||||
return f"{rubles} ₽"
|
||||
@@ -489,6 +523,34 @@ class Settings(BaseSettings):
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
def get_miniapp_branding(self) -> Dict[str, Dict[str, Optional[str]]]:
|
||||
def _clean(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
value_str = str(value).strip()
|
||||
return value_str or None
|
||||
|
||||
name_en = _clean(self.MINIAPP_SERVICE_NAME_EN)
|
||||
name_ru = _clean(self.MINIAPP_SERVICE_NAME_RU)
|
||||
desc_en = _clean(self.MINIAPP_SERVICE_DESCRIPTION_EN)
|
||||
desc_ru = _clean(self.MINIAPP_SERVICE_DESCRIPTION_RU)
|
||||
|
||||
default_name = name_en or name_ru or "RemnaWave VPN"
|
||||
default_description = desc_en or desc_ru or "Secure & Fast Connection"
|
||||
|
||||
return {
|
||||
"service_name": {
|
||||
"default": default_name,
|
||||
"en": name_en,
|
||||
"ru": name_ru,
|
||||
},
|
||||
"service_description": {
|
||||
"default": default_description,
|
||||
"en": desc_en,
|
||||
"ru": desc_ru,
|
||||
},
|
||||
}
|
||||
|
||||
def get_app_config_cache_ttl(self) -> int:
|
||||
return self.APP_CONFIG_CACHE_TTL
|
||||
@@ -556,6 +618,13 @@ class Settings(BaseSettings):
|
||||
def is_happ_download_button_enabled(self) -> bool:
|
||||
return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED
|
||||
|
||||
def should_hide_subscription_link(self) -> bool:
|
||||
"""Returns True when subscription links must be hidden from the interface."""
|
||||
|
||||
if self.is_happ_cryptolink_mode():
|
||||
return False
|
||||
return self.HIDE_SUBSCRIPTION_LINK
|
||||
|
||||
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
|
||||
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
|
||||
return template or None
|
||||
@@ -954,7 +1023,25 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_server_status_request_timeout(self) -> int:
|
||||
return max(1, self.SERVER_STATUS_REQUEST_TIMEOUT)
|
||||
|
||||
|
||||
def is_web_api_enabled(self) -> bool:
|
||||
return bool(self.WEB_API_ENABLED)
|
||||
|
||||
def get_web_api_allowed_origins(self) -> list[str]:
|
||||
raw = (self.WEB_API_ALLOWED_ORIGINS or "").split(",")
|
||||
origins = [origin.strip() for origin in raw if origin.strip()]
|
||||
return origins or ["*"]
|
||||
|
||||
def get_web_api_docs_config(self) -> Dict[str, Optional[str]]:
|
||||
if self.WEB_API_DOCS_ENABLED:
|
||||
return {
|
||||
"docs_url": "/docs",
|
||||
"redoc_url": "/redoc",
|
||||
"openapi_url": "/openapi.json",
|
||||
}
|
||||
|
||||
return {"docs_url": None, "redoc_url": None, "openapi_url": None}
|
||||
|
||||
def get_support_system_mode(self) -> str:
|
||||
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
@@ -974,15 +1061,31 @@ class Settings(BaseSettings):
|
||||
|
||||
settings = Settings()
|
||||
|
||||
PERIOD_PRICES = {
|
||||
14: settings.PRICE_14_DAYS,
|
||||
30: settings.PRICE_30_DAYS,
|
||||
60: settings.PRICE_60_DAYS,
|
||||
90: settings.PRICE_90_DAYS,
|
||||
180: settings.PRICE_180_DAYS,
|
||||
360: settings.PRICE_360_DAYS,
|
||||
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
|
||||
14: "PRICE_14_DAYS",
|
||||
30: "PRICE_30_DAYS",
|
||||
60: "PRICE_60_DAYS",
|
||||
90: "PRICE_90_DAYS",
|
||||
180: "PRICE_180_DAYS",
|
||||
360: "PRICE_360_DAYS",
|
||||
}
|
||||
|
||||
|
||||
def refresh_period_prices() -> None:
|
||||
"""Rebuild cached period price mapping using the latest settings."""
|
||||
|
||||
PERIOD_PRICES.clear()
|
||||
PERIOD_PRICES.update(
|
||||
{
|
||||
days: getattr(settings, field_name, 0)
|
||||
for days, field_name in _PERIOD_PRICE_FIELDS.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
PERIOD_PRICES: Dict[int, int] = {}
|
||||
refresh_period_prices()
|
||||
|
||||
def get_traffic_prices() -> Dict[int, int]:
|
||||
packages = settings.get_traffic_packages()
|
||||
return {package["gb"]: package["price"] for package in packages}
|
||||
|
||||
@@ -32,6 +32,7 @@ async def create_campaign(
|
||||
subscription_traffic_gb: Optional[int] = None,
|
||||
subscription_device_limit: Optional[int] = None,
|
||||
subscription_squads: Optional[List[str]] = None,
|
||||
is_active: bool = True,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
@@ -43,7 +44,7 @@ async def create_campaign(
|
||||
subscription_device_limit=subscription_device_limit,
|
||||
subscription_squads=subscription_squads or [],
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
@@ -135,7 +136,10 @@ async def update_campaign(
|
||||
"is_active",
|
||||
}
|
||||
|
||||
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
|
||||
update_data = {}
|
||||
for key, value in kwargs.items():
|
||||
if key in allowed_fields and value is not None:
|
||||
update_data[key] = value
|
||||
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
@@ -50,3 +50,17 @@ async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notification_by_type(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
SentNotification.subscription_id == subscription_id,
|
||||
SentNotification.notification_type == notification_type,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -30,13 +30,23 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Tuple[PromoGroup, int]]:
|
||||
result = await db.execute(
|
||||
query = (
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
|
||||
)
|
||||
|
||||
if offset:
|
||||
query = query.offset(offset)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.all()
|
||||
|
||||
|
||||
@@ -44,6 +54,11 @@ async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[Pro
|
||||
return await db.get(PromoGroup, group_id)
|
||||
|
||||
|
||||
async def count_promo_groups(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(PromoGroup.id)))
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.is_default.is_(True))
|
||||
@@ -61,6 +76,7 @@ async def create_promo_group(
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: bool = True,
|
||||
is_default: bool = False,
|
||||
) -> PromoGroup:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
|
||||
@@ -70,6 +86,9 @@ async def create_promo_group(
|
||||
else None
|
||||
)
|
||||
|
||||
existing_default = await get_default_promo_group(db)
|
||||
should_be_default = existing_default is None or is_default
|
||||
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
@@ -78,16 +97,26 @@ async def create_promo_group(
|
||||
period_discounts=normalized_period_discounts or None,
|
||||
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=bool(apply_discounts_to_addons),
|
||||
is_default=False,
|
||||
is_default=should_be_default,
|
||||
)
|
||||
|
||||
db.add(promo_group)
|
||||
await db.flush()
|
||||
|
||||
if should_be_default and existing_default and existing_default.id != promo_group.id:
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id != promo_group.id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(promo_group)
|
||||
|
||||
logger.info(
|
||||
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
|
||||
"Создана промогруппа '%s' (default=%s) с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
|
||||
promo_group.name,
|
||||
promo_group.is_default,
|
||||
promo_group.server_discount_percent,
|
||||
promo_group.traffic_discount_percent,
|
||||
promo_group.device_discount_percent,
|
||||
@@ -110,6 +139,7 @@ async def update_promo_group(
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: Optional[bool] = None,
|
||||
is_default: Optional[bool] = None,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
@@ -127,6 +157,36 @@ async def update_promo_group(
|
||||
if apply_discounts_to_addons is not None:
|
||||
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
|
||||
|
||||
if is_default is not None:
|
||||
if is_default:
|
||||
group.is_default = True
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id != group.id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
else:
|
||||
if group.is_default:
|
||||
group.is_default = False
|
||||
await db.flush()
|
||||
replacement = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.id != group.id)
|
||||
.order_by(PromoGroup.id)
|
||||
.limit(1)
|
||||
)
|
||||
new_default = replacement.scalars().first()
|
||||
if new_default:
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id == new_default.id)
|
||||
.values(is_default=True)
|
||||
)
|
||||
else:
|
||||
# Не допускаем состояния без базовой промогруппы
|
||||
group.is_default = True
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
@@ -148,6 +208,7 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
logger.error("Не найдена базовая промогруппа для reassignment")
|
||||
return False
|
||||
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import select, and_, func, update, delete, text
|
||||
from sqlalchemy import (
|
||||
select,
|
||||
and_,
|
||||
func,
|
||||
update,
|
||||
delete,
|
||||
text,
|
||||
or_,
|
||||
cast,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription
|
||||
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -232,10 +243,10 @@ async def sync_with_remnawave(
|
||||
db: AsyncSession,
|
||||
remnawave_squads: List[dict]
|
||||
) -> Tuple[int, int, int]:
|
||||
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
disabled = 0
|
||||
removed = 0
|
||||
|
||||
existing_servers = {}
|
||||
result = await db.execute(select(ServerSquad))
|
||||
@@ -265,15 +276,121 @@ async def sync_with_remnawave(
|
||||
)
|
||||
created += 1
|
||||
|
||||
for uuid, server in existing_servers.items():
|
||||
if uuid not in remnawave_uuids and server.is_available:
|
||||
server.is_available = False
|
||||
disabled += 1
|
||||
|
||||
removed_servers = [
|
||||
server for uuid, server in existing_servers.items()
|
||||
if uuid not in remnawave_uuids
|
||||
]
|
||||
|
||||
if removed_servers:
|
||||
removed_ids = [server.id for server in removed_servers]
|
||||
removed_uuids = {server.squad_uuid for server in removed_servers}
|
||||
|
||||
subscription_ids_result = await db.execute(
|
||||
select(SubscriptionServer.subscription_id)
|
||||
.where(SubscriptionServer.server_squad_id.in_(removed_ids))
|
||||
)
|
||||
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
|
||||
|
||||
for server in removed_servers:
|
||||
logger.info(
|
||||
"🗑️ Удаляется сервер %s (UUID: %s)",
|
||||
server.display_name,
|
||||
server.squad_uuid,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
|
||||
)
|
||||
|
||||
subscriptions_to_update: dict[int, Subscription] = {}
|
||||
|
||||
if subscription_ids:
|
||||
subscriptions_result = await db.execute(
|
||||
select(Subscription).where(Subscription.id.in_(subscription_ids))
|
||||
)
|
||||
for subscription in subscriptions_result.scalars().unique().all():
|
||||
subscriptions_to_update[subscription.id] = subscription
|
||||
|
||||
for squad_uuid in removed_uuids:
|
||||
if not squad_uuid:
|
||||
continue
|
||||
|
||||
extra_result = await db.execute(
|
||||
select(Subscription).where(
|
||||
text("connected_squads::text LIKE :uuid_pattern")
|
||||
),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
|
||||
for subscription in extra_result.scalars().unique().all():
|
||||
subscriptions_to_update[subscription.id] = subscription
|
||||
|
||||
cleaned_subscriptions = 0
|
||||
|
||||
for subscription in subscriptions_to_update.values():
|
||||
current_squads = list(subscription.connected_squads or [])
|
||||
if not current_squads:
|
||||
continue
|
||||
|
||||
filtered_squads = [
|
||||
squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
|
||||
]
|
||||
|
||||
if len(filtered_squads) != len(current_squads):
|
||||
subscription.connected_squads = filtered_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
cleaned_subscriptions += 1
|
||||
|
||||
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
|
||||
removed = len(removed_servers)
|
||||
|
||||
if cleaned_subscriptions:
|
||||
logger.info(
|
||||
"🧹 Обновлены подписки после удаления серверов: %s",
|
||||
cleaned_subscriptions,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
|
||||
return created, updated, disabled
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
|
||||
return created, updated, removed
|
||||
|
||||
|
||||
async def get_server_connected_users(
|
||||
db: AsyncSession,
|
||||
server_id: int
|
||||
) -> List[User]:
|
||||
|
||||
server_uuid_result = await db.execute(
|
||||
select(ServerSquad.squad_uuid).where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_uuid = server_uuid_result.scalar_one_or_none()
|
||||
|
||||
connection_filters = [SubscriptionServer.id.isnot(None)]
|
||||
|
||||
if server_uuid:
|
||||
connection_filters.append(
|
||||
cast(Subscription.connected_squads, String).like(
|
||||
f'%"{server_uuid}"%'
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(Subscription, Subscription.user_id == User.id)
|
||||
.outerjoin(
|
||||
SubscriptionServer,
|
||||
and_(
|
||||
SubscriptionServer.subscription_id == Subscription.id,
|
||||
SubscriptionServer.server_squad_id == server_id,
|
||||
),
|
||||
)
|
||||
.where(or_(*connection_filters))
|
||||
.options(selectinload(User.subscription))
|
||||
.order_by(User.id)
|
||||
)
|
||||
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
@@ -116,13 +116,33 @@ async def extend_subscription(
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
|
||||
|
||||
if subscription.is_trial:
|
||||
start_date = subscription.start_date or current_time
|
||||
total_duration = subscription.end_date - start_date
|
||||
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
|
||||
|
||||
if total_duration > max_trial_duration:
|
||||
subscription.is_trial = False
|
||||
logger.info(
|
||||
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
|
||||
", итоговая длительность: %s дней",
|
||||
subscription.id,
|
||||
total_duration.days,
|
||||
)
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await clear_notifications(db, subscription.id)
|
||||
|
||||
@@ -118,12 +118,7 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
+244
-46
@@ -2,14 +2,24 @@ import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from typing import Optional, List, Dict
|
||||
from sqlalchemy import select, and_, or_, func, case, nullslast, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup
|
||||
from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
PaymentMethod,
|
||||
TransactionType,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,6 +86,37 @@ async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
async def _sync_users_sequence(db: AsyncSession) -> None:
|
||||
"""Ensure the users.id sequence matches the current max ID."""
|
||||
await db.execute(
|
||||
text(
|
||||
"SELECT setval('users_id_seq', "
|
||||
"COALESCE((SELECT MAX(id) FROM users), 0) + 1, false)"
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logger.warning(
|
||||
"🔄 Последовательность users_id_seq была синхронизирована с текущим максимумом id"
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
|
||||
default_group = await get_default_promo_group(db)
|
||||
if default_group:
|
||||
return default_group
|
||||
|
||||
default_group = PromoGroup(
|
||||
name="Базовый юзер",
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
return default_group
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
@@ -90,44 +131,61 @@ async def create_user(
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
default_group = PromoGroup(
|
||||
name="Базовый юзер",
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
attempts = 3
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
|
||||
safe_first = sanitize_telegram_name(first_name)
|
||||
safe_last = sanitize_telegram_name(last_name)
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
|
||||
promo_group_id = default_group.id
|
||||
db.add(user)
|
||||
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if default_group:
|
||||
user.promo_group = default_group
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
user.promo_group = default_group
|
||||
logger.info(
|
||||
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
|
||||
)
|
||||
return user
|
||||
|
||||
return user
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
|
||||
if (
|
||||
isinstance(getattr(exc, "orig", None), Exception)
|
||||
and "users_pkey" in str(exc.orig)
|
||||
and attempt < attempts
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. "
|
||||
"Выполняем повторную синхронизацию (попытка %s/%s)",
|
||||
telegram_id,
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
await _sync_users_sequence(db)
|
||||
continue
|
||||
|
||||
raise
|
||||
|
||||
raise RuntimeError("Не удалось создать пользователя после синхронизации последовательности")
|
||||
|
||||
|
||||
async def update_user(
|
||||
@@ -136,7 +194,10 @@ async def update_user(
|
||||
**kwargs
|
||||
) -> User:
|
||||
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
for field, value in kwargs.items():
|
||||
if field in ("first_name", "last_name"):
|
||||
value = sanitize_telegram_name(value)
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -205,10 +266,12 @@ async def add_user_balance_by_id(
|
||||
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
create_transaction: bool = False,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
) -> bool:
|
||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
@@ -227,7 +290,21 @@ async def subtract_user_balance(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.WITHDRAWAL,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
logger.error(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}")
|
||||
return True
|
||||
|
||||
@@ -243,7 +320,11 @@ async def get_users_list(
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[UserStatus] = None,
|
||||
order_by_balance: bool = False
|
||||
order_by_balance: bool = False,
|
||||
order_by_traffic: bool = False,
|
||||
order_by_last_activity: bool = False,
|
||||
order_by_total_spent: bool = False,
|
||||
order_by_purchase_count: bool = False
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
@@ -263,10 +344,71 @@ async def get_users_list(
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
# Сортировка по балансу в порядке убывания, если order_by_balance=True
|
||||
if order_by_balance:
|
||||
query = query.order_by(User.balance_kopeks.desc())
|
||||
|
||||
sort_flags = [
|
||||
order_by_balance,
|
||||
order_by_traffic,
|
||||
order_by_last_activity,
|
||||
order_by_total_spent,
|
||||
order_by_purchase_count,
|
||||
]
|
||||
if sum(int(flag) for flag in sort_flags) > 1:
|
||||
logger.debug(
|
||||
"Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность"
|
||||
)
|
||||
|
||||
transactions_stats = None
|
||||
if order_by_total_spent or order_by_purchase_count:
|
||||
from app.database.models import Transaction
|
||||
|
||||
transactions_stats = (
|
||||
select(
|
||||
Transaction.user_id.label("user_id"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("total_spent"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("purchase_count"),
|
||||
)
|
||||
.where(Transaction.is_completed.is_(True))
|
||||
.group_by(Transaction.user_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.outerjoin(transactions_stats, transactions_stats.c.user_id == User.id)
|
||||
|
||||
if order_by_traffic:
|
||||
traffic_sort = func.coalesce(Subscription.traffic_used_gb, 0.0)
|
||||
query = query.outerjoin(Subscription, Subscription.user_id == User.id)
|
||||
query = query.order_by(traffic_sort.desc(), User.created_at.desc())
|
||||
elif order_by_total_spent:
|
||||
order_column = func.coalesce(transactions_stats.c.total_spent, 0)
|
||||
query = query.order_by(order_column.desc(), User.created_at.desc())
|
||||
elif order_by_purchase_count:
|
||||
order_column = func.coalesce(transactions_stats.c.purchase_count, 0)
|
||||
query = query.order_by(order_column.desc(), User.created_at.desc())
|
||||
elif order_by_balance:
|
||||
query = query.order_by(User.balance_kopeks.desc(), User.created_at.desc())
|
||||
elif order_by_last_activity:
|
||||
query = query.order_by(nullslast(User.last_activity.desc()), User.created_at.desc())
|
||||
else:
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
@@ -304,6 +446,62 @@ async def get_users_count(
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_users_spending_stats(
|
||||
db: AsyncSession,
|
||||
user_ids: List[int]
|
||||
) -> Dict[int, Dict[str, int]]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
from app.database.models import Transaction
|
||||
|
||||
stats_query = (
|
||||
select(
|
||||
Transaction.user_id,
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("total_spent"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("purchase_count"),
|
||||
)
|
||||
.where(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
|
||||
result = await db.execute(stats_query)
|
||||
rows = result.all()
|
||||
|
||||
return {
|
||||
row.user_id: {
|
||||
"total_spent": int(row.total_spent or 0),
|
||||
"purchase_count": int(row.purchase_count or 0),
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""CRUD операции для токенов административного веб-API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WebApiToken
|
||||
|
||||
|
||||
async def list_tokens(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> List[WebApiToken]:
|
||||
query = select(WebApiToken)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(WebApiToken.is_active.is_(True))
|
||||
|
||||
query = query.order_by(WebApiToken.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
|
||||
return await db.get(WebApiToken, token_id)
|
||||
|
||||
|
||||
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
|
||||
query = select(WebApiToken).where(
|
||||
WebApiToken.token_hash == token_hash
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_token(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> WebApiToken:
|
||||
token = WebApiToken(
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=token_prefix,
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(token)
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def update_token(
|
||||
db: AsyncSession,
|
||||
token: WebApiToken,
|
||||
**kwargs,
|
||||
) -> WebApiToken:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(token, key):
|
||||
setattr(token, key, value)
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def set_tokens_active_status(
|
||||
db: AsyncSession,
|
||||
token_ids: Iterable[int],
|
||||
*,
|
||||
is_active: bool,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
update(WebApiToken)
|
||||
.where(WebApiToken.id.in_(list(token_ids)))
|
||||
.values(is_active=is_active, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
|
||||
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
|
||||
await db.delete(token)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"list_tokens",
|
||||
"get_token_by_id",
|
||||
"get_token_by_hash",
|
||||
"create_token",
|
||||
"update_token",
|
||||
"set_tokens_active_status",
|
||||
"delete_token",
|
||||
]
|
||||
@@ -10,7 +10,7 @@ from app.database.models import Base
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
settings.get_database_url(),
|
||||
poolclass=NullPool,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
@@ -48,4 +48,4 @@ async def init_db():
|
||||
|
||||
async def close_db():
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
|
||||
+25
-5
@@ -571,8 +571,7 @@ class Subscription(Base):
|
||||
return 0.0
|
||||
|
||||
def extend_subscription(self, days: int):
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
|
||||
if self.end_date > datetime.utcnow():
|
||||
self.end_date = self.end_date + timedelta(days=days)
|
||||
else:
|
||||
@@ -1091,7 +1090,7 @@ class Ticket(Base):
|
||||
|
||||
class TicketMessage(Base):
|
||||
__tablename__ = "ticket_messages"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
@@ -1118,6 +1117,27 @@ class TicketMessage(Base):
|
||||
@property
|
||||
def is_admin_message(self) -> bool:
|
||||
return self.is_from_admin
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
||||
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
||||
|
||||
|
||||
class WebApiToken(Base):
|
||||
__tablename__ = "web_api_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
token_hash = Column(String(128), nullable=False, unique=True, index=True)
|
||||
token_prefix = Column(String(32), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
last_used_ip = Column(String(64), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(String(255), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "revoked"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
@@ -1,13 +1,109 @@
|
||||
import logging
|
||||
from sqlalchemy import text, inspect
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.database import engine
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal, engine
|
||||
from app.database.models import WebApiToken
|
||||
from app.utils.security import hash_api_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_database_type():
|
||||
return engine.dialect.name
|
||||
|
||||
|
||||
async def sync_postgres_sequences() -> bool:
|
||||
"""Ensure PostgreSQL sequences match the current max values after restores."""
|
||||
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type != "postgresql":
|
||||
logger.debug("Пропускаем синхронизацию последовательностей: тип БД %s", db_type)
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
cols.table_schema,
|
||||
cols.table_name,
|
||||
cols.column_name,
|
||||
pg_get_serial_sequence(
|
||||
format('%I.%I', cols.table_schema, cols.table_name),
|
||||
cols.column_name
|
||||
) AS sequence_path
|
||||
FROM information_schema.columns AS cols
|
||||
WHERE cols.column_default LIKE 'nextval(%'
|
||||
AND cols.table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
sequences = result.fetchall()
|
||||
|
||||
if not sequences:
|
||||
logger.info("ℹ️ Не найдено последовательностей PostgreSQL для синхронизации")
|
||||
return True
|
||||
|
||||
for table_schema, table_name, column_name, sequence_path in sequences:
|
||||
if not sequence_path:
|
||||
continue
|
||||
|
||||
max_result = await conn.execute(
|
||||
text(
|
||||
f'SELECT COALESCE(MAX("{column_name}"), 0) '
|
||||
f'FROM "{table_schema}"."{table_name}"'
|
||||
)
|
||||
)
|
||||
max_value = max_result.scalar() or 0
|
||||
|
||||
parts = sequence_path.split('.')
|
||||
if len(parts) == 2:
|
||||
seq_schema, seq_name = parts
|
||||
else:
|
||||
seq_schema, seq_name = 'public', parts[-1]
|
||||
|
||||
seq_schema = seq_schema.strip('"')
|
||||
seq_name = seq_name.strip('"')
|
||||
current_result = await conn.execute(
|
||||
text(
|
||||
f'SELECT last_value, is_called FROM "{seq_schema}"."{seq_name}"'
|
||||
)
|
||||
)
|
||||
current_row = current_result.fetchone()
|
||||
|
||||
if current_row:
|
||||
current_last, is_called = current_row
|
||||
current_next = current_last + 1 if is_called else current_last
|
||||
if current_next > max_value:
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT setval(:sequence_name, :new_value, TRUE)
|
||||
"""
|
||||
),
|
||||
{"sequence_name": sequence_path, "new_value": max_value},
|
||||
)
|
||||
logger.info(
|
||||
"🔄 Последовательность %s синхронизирована: MAX=%s, следующий ID=%s",
|
||||
sequence_path,
|
||||
max_value,
|
||||
max_value + 1,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("❌ Ошибка синхронизации последовательностей PostgreSQL: %s", error)
|
||||
return False
|
||||
|
||||
async def check_table_exists(table_name: str) -> bool:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -978,6 +1074,7 @@ async def ensure_promo_groups_setup():
|
||||
logger.info(
|
||||
"Добавлена колонка promo_groups.apply_discounts_to_addons"
|
||||
)
|
||||
addon_discount_column_exists = True
|
||||
|
||||
column_exists = await check_column_exists("users", "promo_group_id")
|
||||
|
||||
@@ -1110,9 +1207,25 @@ async def ensure_promo_groups_setup():
|
||||
if existing_default:
|
||||
default_group_id = existing_default[0]
|
||||
else:
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
insert_params = {
|
||||
"name": default_group_name,
|
||||
"is_default": True,
|
||||
}
|
||||
|
||||
if addon_discount_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
apply_discounts_to_addons,
|
||||
is_default
|
||||
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
|
||||
"""
|
||||
insert_params["apply_discounts_to_addons"] = True
|
||||
else:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
server_discount_percent,
|
||||
@@ -1121,9 +1234,8 @@ async def ensure_promo_groups_setup():
|
||||
is_default
|
||||
) VALUES (:name, 0, 0, 0, :is_default)
|
||||
"""
|
||||
),
|
||||
{"name": default_group_name, "is_default": True},
|
||||
)
|
||||
|
||||
await conn.execute(text(insert_sql), insert_params)
|
||||
|
||||
result = await conn.execute(
|
||||
text(
|
||||
@@ -1886,17 +1998,154 @@ async def create_system_settings_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_web_api_tokens_table() -> bool:
|
||||
table_exists = await check_table_exists("web_api_tokens")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица web_api_tokens уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE web_api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NULL,
|
||||
last_used_at DATETIME NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_by VARCHAR(255) NULL
|
||||
);
|
||||
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
|
||||
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
|
||||
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE web_api_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by VARCHAR(255) NULL
|
||||
);
|
||||
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
|
||||
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
|
||||
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE web_api_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by VARCHAR(255) NULL
|
||||
) ENGINE=InnoDB;
|
||||
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
|
||||
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
|
||||
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица web_api_tokens создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы web_api_tokens: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_default_web_api_token() -> bool:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if not default_token:
|
||||
return True
|
||||
|
||||
token_name = (settings.WEB_API_DEFAULT_TOKEN_NAME or "Bootstrap Token").strip()
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
token_hash = hash_api_token(default_token, settings.WEB_API_TOKEN_HASH_ALGORITHM)
|
||||
result = await session.execute(
|
||||
select(WebApiToken).where(WebApiToken.token_hash == token_hash)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
updated = False
|
||||
|
||||
if not existing.is_active:
|
||||
existing.is_active = True
|
||||
updated = True
|
||||
|
||||
if token_name and existing.name != token_name:
|
||||
existing.name = token_name
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
existing.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
token = WebApiToken(
|
||||
name=token_name or "Bootstrap Token",
|
||||
token_hash=token_hash,
|
||||
token_prefix=default_token[:12],
|
||||
description="Автоматически создан при миграции",
|
||||
created_by="migration",
|
||||
is_active=True,
|
||||
)
|
||||
session.add(token)
|
||||
await session.commit()
|
||||
logger.info("✅ Создан дефолтный токен веб-API из конфигурации")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания дефолтного веб-API токена: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
try:
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Тип базы данных: {db_type}")
|
||||
|
||||
|
||||
if db_type == 'postgresql':
|
||||
logger.info("=== СИНХРОНИЗАЦИЯ ПОСЛЕДОВАТЕЛЬНОСТЕЙ PostgreSQL ===")
|
||||
sequences_synced = await sync_postgres_sequences()
|
||||
if sequences_synced:
|
||||
logger.info("✅ Последовательности PostgreSQL синхронизированы")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось синхронизировать последовательности PostgreSQL")
|
||||
|
||||
referral_migration_success = await add_referral_system_columns()
|
||||
if not referral_migration_success:
|
||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===")
|
||||
system_settings_ready = await create_system_settings_table()
|
||||
if system_settings_ready:
|
||||
@@ -1904,6 +2153,20 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей system_settings")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WEB_API_TOKENS ===")
|
||||
web_api_tokens_ready = await create_web_api_tokens_table()
|
||||
if web_api_tokens_ready:
|
||||
logger.info("✅ Таблица web_api_tokens готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
|
||||
|
||||
logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===")
|
||||
default_token_ready = await ensure_default_web_api_token()
|
||||
if default_token_ready:
|
||||
logger.info("✅ Бутстрап токен веб-API готов")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось создать бутстрап токен веб-API")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===")
|
||||
cryptobot_created = await create_cryptobot_payments_table()
|
||||
if cryptobot_created:
|
||||
|
||||
Vendored
-1
@@ -4,7 +4,6 @@ import ssl
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Union, Any
|
||||
from urllib.parse import urlparse
|
||||
import aiohttp
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
Vendored
+91
-11
@@ -1,8 +1,9 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
from typing import Optional, Iterable
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
@@ -111,14 +112,18 @@ class WebhookServer:
|
||||
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
|
||||
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Пустой Mulen Pay webhook")
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
# Временно отключаем проверку подписи для отладки
|
||||
# TODO: Включить обратно после настройки MulenPay
|
||||
if not self._verify_mulenpay_signature(request, raw_body):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
|
||||
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
@@ -139,11 +144,22 @@ class WebhookServer:
|
||||
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
|
||||
finally:
|
||||
break
|
||||
|
||||
# Если не удалось получить соединение с БД
|
||||
return web.json_response({"status": "error", "reason": "database_error"}, status=500)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
|
||||
|
||||
@staticmethod
|
||||
def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]:
|
||||
for header_name in header_names:
|
||||
value = request.headers.get(header_name)
|
||||
if value:
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
|
||||
secret_key = settings.MULENPAY_SECRET_KEY
|
||||
@@ -151,28 +167,92 @@ class WebhookServer:
|
||||
logger.error("Mulen Pay secret key is not configured")
|
||||
return False
|
||||
|
||||
signature = request.headers.get('X-MulenPay-Signature')
|
||||
# Логируем все заголовки для отладки
|
||||
logger.info("MulenPay webhook headers for signature verification:")
|
||||
for header_name, header_value in request.headers.items():
|
||||
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
|
||||
logger.info(f" {header_name}: {header_value}")
|
||||
|
||||
signature = WebhookServer._extract_mulenpay_header(
|
||||
request,
|
||||
(
|
||||
'X-MulenPay-Signature',
|
||||
'X-Mulenpay-Signature',
|
||||
'X-MULENPAY-SIGNATURE',
|
||||
'X-MulenPay-Webhook-Signature',
|
||||
'X-Mulenpay-Webhook-Signature',
|
||||
'X-MULENPAY-WEBHOOK-SIGNATURE',
|
||||
'X-Signature',
|
||||
'Signature',
|
||||
'X-MulenPay-Sign',
|
||||
'X-Mulenpay-Sign',
|
||||
'X-MULENPAY-SIGN',
|
||||
'MulenPay-Signature',
|
||||
'Mulenpay-Signature',
|
||||
'MULENPAY-SIGNATURE',
|
||||
'signature',
|
||||
'sign',
|
||||
)
|
||||
)
|
||||
if signature:
|
||||
expected_signature = hmac.new(
|
||||
normalized_signature = signature
|
||||
if normalized_signature.lower().startswith('sha256='):
|
||||
normalized_signature = normalized_signature.split('=', 1)[1].strip()
|
||||
|
||||
hmac_digest = hmac.new(
|
||||
secret_key.encode('utf-8'),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
).digest()
|
||||
expected_hex_signature = hmac_digest.hex()
|
||||
expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip()
|
||||
expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip()
|
||||
|
||||
if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()):
|
||||
normalized_signature_lower = normalized_signature.lower()
|
||||
if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()):
|
||||
return True
|
||||
|
||||
normalized_signature_no_padding = normalized_signature.rstrip('=')
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
logger.error("Неверная подпись Mulen Pay webhook")
|
||||
return False
|
||||
|
||||
authorization_header = request.headers.get('Authorization')
|
||||
if authorization_header and authorization_header.startswith('Bearer '):
|
||||
token = authorization_header.split(' ', 1)[1].strip()
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
if authorization_header:
|
||||
scheme, _, value = authorization_header.partition(' ')
|
||||
scheme_lower = scheme.lower()
|
||||
token = value.strip() if value else scheme.strip()
|
||||
|
||||
if scheme_lower in ('bearer', 'token'):
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
|
||||
return False
|
||||
|
||||
if not value and hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
logger.error("Неверный Bearer токен Mulen Pay webhook")
|
||||
return False
|
||||
fallback_token = WebhookServer._extract_mulenpay_header(
|
||||
request,
|
||||
(
|
||||
'X-MulenPay-Token',
|
||||
'X-Mulenpay-Token',
|
||||
'X-Webhook-Token',
|
||||
)
|
||||
)
|
||||
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
"Mulen Pay webhook headers received: %s",
|
||||
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
|
||||
)
|
||||
|
||||
logger.error("Отсутствует подпись Mulen Pay webhook")
|
||||
return False
|
||||
|
||||
+1150
-109
File diff suppressed because it is too large
Load Diff
+40
-30
@@ -71,10 +71,10 @@ async def show_users_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"👥 **Управление пользователями и подписками**\n\n"
|
||||
"Выберите нужный раздел:",
|
||||
texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") +
|
||||
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
|
||||
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -89,10 +89,10 @@ async def show_promo_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"💰 **Промокоды и статистика**\n\n"
|
||||
"Выберите нужный раздел:",
|
||||
texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") +
|
||||
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
|
||||
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -107,10 +107,10 @@ async def show_communications_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 **Коммуникации**\n\n"
|
||||
"Управление рассылками и текстами интерфейса:",
|
||||
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") +
|
||||
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"),
|
||||
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -128,16 +128,19 @@ async def show_support_submenu(
|
||||
# Moderators have access only to tickets and not to settings
|
||||
is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
|
||||
from app.keyboards.admin import get_admin_support_submenu_keyboard
|
||||
kb = get_admin_support_submenu_keyboard(db_user.language)
|
||||
if is_moderator_only:
|
||||
# Rebuild keyboard to include only tickets and back to main menu
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
|
||||
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
"🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"),
|
||||
texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + (
|
||||
texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.")
|
||||
if is_moderator_only
|
||||
else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:")
|
||||
),
|
||||
reply_markup=kb,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -150,12 +153,14 @@ async def show_moderator_panel(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")]
|
||||
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
"🧑⚖️ <b>Модерация поддержки</b>\n\nДоступ к тикетам поддержки.",
|
||||
texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑⚖️ <b>Модерация поддержки</b>") + "\n\n" +
|
||||
texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."),
|
||||
parse_mode="HTML",
|
||||
reply_markup=kb
|
||||
)
|
||||
@@ -169,6 +174,7 @@ async def show_support_audit(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
# pagination
|
||||
page = 1
|
||||
if callback.data.startswith("admin_support_audit_page_"):
|
||||
@@ -186,18 +192,22 @@ async def show_support_audit(
|
||||
offset = (page - 1) * per_page
|
||||
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
|
||||
|
||||
lines = ["🧾 <b>Аудит модераторов</b>", ""]
|
||||
lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 <b>Аудит модераторов</b>"), ""]
|
||||
if not logs:
|
||||
lines.append("Пока пусто")
|
||||
lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто"))
|
||||
else:
|
||||
for log in logs:
|
||||
role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ"
|
||||
role = (
|
||||
texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор")
|
||||
if getattr(log, 'is_moderator', False)
|
||||
else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ")
|
||||
)
|
||||
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
|
||||
action_map = {
|
||||
'close_ticket': 'Закрытие тикета',
|
||||
'block_user_timed': 'Блокировка (время)',
|
||||
'block_user_perm': 'Блокировка (навсегда)',
|
||||
'unblock_user': 'Снятие блока',
|
||||
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
|
||||
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
|
||||
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
|
||||
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
|
||||
}
|
||||
action_text = action_map.get(log.action, log.action)
|
||||
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
|
||||
@@ -219,7 +229,7 @@ async def show_support_audit(
|
||||
kb_rows = []
|
||||
if nav_row:
|
||||
kb_rows.append(nav_row)
|
||||
kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")])
|
||||
kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")])
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
|
||||
|
||||
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
|
||||
@@ -234,10 +244,10 @@ async def show_settings_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⚙️ **Настройки системы**\n\n"
|
||||
"Управление Remnawave, мониторингом и другими настройками:",
|
||||
texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") +
|
||||
texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"),
|
||||
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
@@ -252,10 +262,10 @@ async def show_system_submenu(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🛠️ **Системные функции**\n\n"
|
||||
"Отчеты, обновления, резервные копии и системные операции:",
|
||||
texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") +
|
||||
texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"),
|
||||
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
@@ -21,8 +21,8 @@ from app.keyboards.admin import (
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
|
||||
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
|
||||
BROADCAST_BUTTONS, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
|
||||
BROADCAST_BUTTON_LABELS
|
||||
BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
|
||||
get_broadcast_button_config, get_broadcast_button_labels
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
@@ -31,10 +31,8 @@ from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BUTTON_CONFIG = BROADCAST_BUTTONS
|
||||
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
|
||||
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
|
||||
BUTTON_LABELS = BROADCAST_BUTTON_LABELS
|
||||
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
@@ -45,16 +43,17 @@ def get_updated_message_buttons_selector_keyboard(selected_buttons: list, langua
|
||||
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKeyboardMarkup]:
|
||||
def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]:
|
||||
selected_buttons = selected_buttons or []
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
button_config_map = get_broadcast_button_config(language)
|
||||
|
||||
for row in BUTTON_ROWS:
|
||||
row_buttons: list[types.InlineKeyboardButton] = []
|
||||
for button_key in row:
|
||||
if button_key not in selected_buttons:
|
||||
continue
|
||||
button_config = BUTTON_CONFIG[button_key]
|
||||
button_config = button_config_map[button_key]
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=button_config["text"],
|
||||
@@ -628,7 +627,8 @@ async def confirm_button_selection(
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
|
||||
|
||||
ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row]
|
||||
selected_names = [BUTTON_LABELS[key] for key in ordered_keys if key in selected_buttons]
|
||||
button_labels = get_broadcast_button_labels(db_user.language)
|
||||
selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons]
|
||||
if selected_names:
|
||||
buttons_info = f"\n📘 <b>Кнопки:</b> {', '.join(selected_names)}"
|
||||
else:
|
||||
@@ -745,7 +745,7 @@ async def confirm_broadcast(
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons)
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
|
||||
@@ -37,6 +37,9 @@ def _build_notification_settings_view(language: str):
|
||||
|
||||
trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
|
||||
trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
|
||||
trial_channel_status = _format_toggle(
|
||||
config["trial_channel_unsubscribed"].get("enabled", True)
|
||||
)
|
||||
expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
|
||||
second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
|
||||
third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
|
||||
@@ -45,6 +48,7 @@ def _build_notification_settings_view(language: str):
|
||||
"🔔 <b>Уведомления пользователям</b>\n\n"
|
||||
f"• 1 час после триала: {trial_1h_status}\n"
|
||||
f"• 24 часа после триала: {trial_24h_status}\n"
|
||||
f"• Отписка от канала: {trial_channel_status}\n"
|
||||
f"• 1 день после истечения: {expired_1d_status}\n"
|
||||
f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
|
||||
f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
|
||||
@@ -57,6 +61,8 @@ def _build_notification_settings_view(language: str):
|
||||
[InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")],
|
||||
[InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")],
|
||||
[InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")],
|
||||
[InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")],
|
||||
[InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
|
||||
@@ -153,6 +159,36 @@ def _build_notification_preview_message(language: str, notification_type: str):
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "trial_channel_unsubscribed":
|
||||
template = texts.get(
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED",
|
||||
(
|
||||
"🚫 <b>Доступ приостановлен</b>\n\n"
|
||||
"Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
|
||||
"Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
|
||||
),
|
||||
)
|
||||
check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
|
||||
message = template.format(check_button=check_button)
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
if settings.CHANNEL_LINK:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
||||
url=settings.CHANNEL_LINK,
|
||||
)
|
||||
]
|
||||
)
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=check_button,
|
||||
callback_data="sub_channel_check",
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
elif notification_type == "expired_1d":
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_1D",
|
||||
@@ -368,7 +404,8 @@ async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
🔧 Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = get_monitoring_keyboard()
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
keyboard = get_monitoring_keyboard(language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
@@ -461,6 +498,27 @@ async def preview_trial_24h_notification(callback: CallbackQuery):
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel")
|
||||
@admin_required
|
||||
async def toggle_trial_channel_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
|
||||
NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel")
|
||||
@admin_required
|
||||
async def preview_trial_channel_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial channel preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
|
||||
@admin_required
|
||||
async def toggle_expired_1d_notification(callback: CallbackQuery):
|
||||
@@ -533,6 +591,7 @@ async def preview_all_notifications(callback: CallbackQuery):
|
||||
for notification_type in [
|
||||
"trial_inactive_1h",
|
||||
"trial_inactive_24h",
|
||||
"trial_channel_unsubscribed",
|
||||
"expired_1d",
|
||||
"expired_2d",
|
||||
"expired_nd",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -15,6 +16,7 @@ from app.database.crud.server_squad import (
|
||||
create_server_squad,
|
||||
get_available_server_squads,
|
||||
update_server_squad_promo_groups,
|
||||
get_server_connected_users,
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
@@ -73,6 +75,11 @@ def _build_server_edit_view(server):
|
||||
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
|
||||
@@ -164,7 +171,7 @@ async def show_servers_menu(
|
||||
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
]
|
||||
|
||||
@@ -276,7 +283,7 @@ async def sync_servers_with_remnawave(
|
||||
)
|
||||
return
|
||||
|
||||
created, updated, disabled = await sync_with_remnawave(db, squads)
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
@@ -286,7 +293,7 @@ async def sync_servers_with_remnawave(
|
||||
📊 <b>Результаты:</b>
|
||||
• Создано новых серверов: {created}
|
||||
• Обновлено существующих: {updated}
|
||||
• Отключено неактивных: {disabled}
|
||||
• Удалено отсутствующих: {removed}
|
||||
• Всего обработано: {len(squads)}
|
||||
|
||||
ℹ️ Новые серверы созданы как недоступные.
|
||||
@@ -314,7 +321,7 @@ async def sync_servers_with_remnawave(
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -343,6 +350,149 @@ async def show_server_edit_menu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_users(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
payload = callback.data.split("admin_server_users_", 1)[-1]
|
||||
payload_parts = payload.split("_")
|
||||
|
||||
server_id = int(payload_parts[0])
|
||||
page = int(payload_parts[1]) if len(payload_parts) > 1 else 1
|
||||
page = max(page, 1)
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
users = await get_server_connected_users(db, server_id)
|
||||
total_users = len(users)
|
||||
|
||||
page_size = 10
|
||||
total_pages = max((total_users + page_size - 1) // page_size, 1)
|
||||
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
start_index = (page - 1) * page_size
|
||||
end_index = start_index + page_size
|
||||
page_users = users[start_index:end_index]
|
||||
|
||||
safe_name = html.escape(server.display_name or "—")
|
||||
safe_uuid = html.escape(server.squad_uuid or "—")
|
||||
|
||||
header = [
|
||||
"🌐 <b>Пользователи сервера</b>",
|
||||
"",
|
||||
f"• Сервер: {safe_name}",
|
||||
f"• UUID: <code>{safe_uuid}</code>",
|
||||
f"• Подключений: {total_users}",
|
||||
]
|
||||
|
||||
if total_pages > 1:
|
||||
header.append(f"• Страница: {page}/{total_pages}")
|
||||
|
||||
header.append("")
|
||||
|
||||
text = "\n".join(header)
|
||||
|
||||
def _get_status_icon(status_text: str) -> str:
|
||||
if not status_text:
|
||||
return ""
|
||||
|
||||
parts = status_text.split(" ", 1)
|
||||
return parts[0] if parts else status_text
|
||||
|
||||
if users:
|
||||
lines = []
|
||||
for index, user in enumerate(page_users, start=start_index + 1):
|
||||
safe_user_name = html.escape(user.full_name)
|
||||
lines.append(f"{index}. {safe_user_name}")
|
||||
|
||||
text += "\n" + "\n".join(lines)
|
||||
else:
|
||||
text += "Пользователи не найдены."
|
||||
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
for user in page_users:
|
||||
display_name = user.full_name
|
||||
if len(display_name) > 30:
|
||||
display_name = display_name[:27] + "..."
|
||||
|
||||
subscription_status = (
|
||||
user.subscription.status_display
|
||||
if user.subscription
|
||||
else "❌ Нет подписки"
|
||||
)
|
||||
status_icon = _get_status_icon(subscription_status)
|
||||
|
||||
if status_icon:
|
||||
button_text = f"{status_icon} {display_name}"
|
||||
else:
|
||||
button_text = display_name
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}",
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_buttons: list[types.InlineKeyboardButton] = []
|
||||
|
||||
if page > 1:
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Предыдущая",
|
||||
callback_data=f"admin_server_users_{server_id}_{page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"Стр. {page}/{total_pages}",
|
||||
callback_data=f"admin_server_users_{server_id}_{page}",
|
||||
)
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="Следующая ➡️",
|
||||
callback_data=f"admin_server_users_{server_id}_{page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(navigation_buttons)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ К серверу", callback_data=f"admin_server_edit_{server_id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ К списку", callback_data="admin_servers_list"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_availability(
|
||||
@@ -1105,6 +1255,7 @@ def register_handlers(dp: Dispatcher):
|
||||
& ~F.data.contains("promo"),
|
||||
)
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
|
||||
|
||||
@@ -4,7 +4,6 @@ from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_subscriptions_keyboard
|
||||
@@ -87,10 +86,6 @@ async def show_subscriptions_menu(
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
|
||||
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
|
||||
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
@@ -277,56 +272,6 @@ async def show_subscriptions_stats(
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_pricing_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = f"""
|
||||
⚙️ <b>Настройки цен</b>
|
||||
|
||||
<b>Периоды подписки:</b>
|
||||
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
|
||||
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
|
||||
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
|
||||
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
|
||||
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
|
||||
|
||||
<b>Трафик-пакеты:</b>
|
||||
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
|
||||
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
|
||||
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
|
||||
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
|
||||
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
|
||||
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
|
||||
|
||||
<b>Дополнительно:</b>
|
||||
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
|
||||
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
|
||||
# ],
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
|
||||
# ],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_countries_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -487,7 +432,6 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
|
||||
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
|
||||
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
|
||||
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
|
||||
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
|
||||
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import html
|
||||
import contextlib
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
@@ -28,33 +29,63 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
|
||||
rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены")
|
||||
status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены")
|
||||
|
||||
def mode_button(label_key: str, default: str, active: bool) -> str:
|
||||
prefix = "🔘" if active else "⚪"
|
||||
return f"{prefix} {texts.t(label_key, default)}"
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=("✅ Пункт 'Техподдержка' в меню" if menu_enabled else "🚫 Пункт 'Техподдержка' в меню"),
|
||||
text=(
|
||||
f"{'✅' if menu_enabled else '🚫'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}"
|
||||
),
|
||||
callback_data="admin_support_toggle_menu"
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text=("🔘 Тикеты" if mode == "tickets" else "⚪ Тикеты"), callback_data="admin_support_mode_tickets"),
|
||||
types.InlineKeyboardButton(text=("🔘 Контакт" if mode == "contact" else "⚪ Контакт"), callback_data="admin_support_mode_contact"),
|
||||
types.InlineKeyboardButton(text=("🔘 Оба" if mode == "both" else "⚪ Оба"), callback_data="admin_support_mode_both"),
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"),
|
||||
callback_data="admin_support_mode_tickets"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"),
|
||||
callback_data="admin_support_mode_contact"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"),
|
||||
callback_data="admin_support_mode_both"
|
||||
),
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc")
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"),
|
||||
callback_data="admin_support_edit_desc"
|
||||
)
|
||||
])
|
||||
|
||||
# Notifications block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"),
|
||||
text=(
|
||||
f"{'🔔' if admin_notif else '🔕'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: "
|
||||
f"{status_enabled if admin_notif else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_admin_notifications"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"),
|
||||
text=(
|
||||
f"{'🔔' if user_notif else '🔕'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: "
|
||||
f"{status_enabled if user_notif else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_user_notifications"
|
||||
)
|
||||
])
|
||||
@@ -62,13 +93,17 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
# SLA block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"),
|
||||
text=(
|
||||
f"{'⏰' if sla_enabled else '⏹️'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: "
|
||||
f"{status_enabled if sla_enabled else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_sla"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"⏳ Время SLA: {sla_minutes} мин",
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes),
|
||||
callback_data="admin_support_set_sla_minutes"
|
||||
)
|
||||
])
|
||||
@@ -78,15 +113,18 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
mod_count = len(moderators)
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"🧑⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators"
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑⚖️ Модераторы: {count}").format(count=mod_count),
|
||||
callback_data="admin_support_list_moderators"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="➕ Назначить модератора", callback_data="admin_support_add_moderator"
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"),
|
||||
callback_data="admin_support_add_moderator"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="➖ Удалить модератора", callback_data="admin_support_remove_moderator"
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"),
|
||||
callback_data="admin_support_remove_moderator"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -107,8 +145,8 @@ async def show_support_settings(
|
||||
texts = get_texts(db_user.language)
|
||||
desc = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
"🛟 <b>Настройки поддержки</b>\n\n" +
|
||||
"Режим работы и видимость в меню. Ниже текущее описание меню поддержки:\n\n" +
|
||||
texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 <b>Настройки поддержки</b>") + "\n\n" +
|
||||
texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" +
|
||||
desc,
|
||||
reply_markup=_get_support_settings_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
@@ -152,24 +190,6 @@ async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSess
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
from app.states import SupportSettingsStates
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
await callback.message.edit_text(
|
||||
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below
|
||||
# we'll manage separate state below
|
||||
|
||||
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
class SupportAdvancedStates(StatesGroup):
|
||||
waiting_for_sla_minutes = State()
|
||||
waiting_for_moderator_id = State()
|
||||
@@ -178,11 +198,15 @@ class SupportAdvancedStates(StatesGroup):
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_SLA_SETUP_PROMPT",
|
||||
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
|
||||
@@ -192,63 +216,53 @@ async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
minutes = int(text)
|
||||
if minutes <= 0 or minutes > 1440:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
await message.answer("❌ Введите корректное число минут (1-1440)")
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)"))
|
||||
return
|
||||
SupportSettingsService.set_sla_minutes(minutes)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer("✅ Значение SLA сохранено", reply_markup=markup)
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
"🧑⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT",
|
||||
"🧑⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
tid = int(text)
|
||||
except Exception:
|
||||
await message.answer("❌ Введите корректный Telegram ID (число)")
|
||||
return
|
||||
if SupportSettingsService.add_moderator(tid):
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup)
|
||||
else:
|
||||
await message.answer("❌ Не удалось сохранить")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
"🧑⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT",
|
||||
"🧑⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
@@ -260,24 +274,32 @@ async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, d
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
action = data.get("action", "add")
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
tid = int(text)
|
||||
except Exception:
|
||||
await message.answer("❌ Введите корректный Telegram ID (число)")
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)"))
|
||||
return
|
||||
ok = False
|
||||
if action == "remove_moderator":
|
||||
ok = SupportSettingsService.remove_moderator(tid)
|
||||
msg = "✅ Модератор удалён" if ok else "❌ Не удалось удалить"
|
||||
msg = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid)
|
||||
if ok
|
||||
else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора")
|
||||
)
|
||||
else:
|
||||
ok = SupportSettingsService.add_moderator(tid)
|
||||
msg = "✅ Пользователь назначен модератором" if ok else "❌ Не удалось назначить"
|
||||
msg = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid)
|
||||
if ok
|
||||
else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора")
|
||||
)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(msg, reply_markup=markup)
|
||||
|
||||
@@ -285,13 +307,17 @@ async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSe
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
moderators = SupportSettingsService.get_moderators()
|
||||
if not moderators:
|
||||
await callback.answer("Список пуст", show_alert=True)
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True)
|
||||
return
|
||||
text = "🧑⚖️ <b>Модераторы</b>\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
|
||||
text = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑⚖️ <b>Модераторы</b>") +
|
||||
"\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
|
||||
)
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -328,7 +354,10 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
|
||||
|
||||
kb_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(text="📨 Прислать текст", callback_data="admin_support_send_desc")
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"),
|
||||
callback_data="admin_support_send_desc"
|
||||
)
|
||||
])
|
||||
# Подготовим блок контакта (отдельным инлайном)
|
||||
from app.config import settings
|
||||
@@ -338,19 +367,19 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
|
||||
])
|
||||
|
||||
text_parts = [
|
||||
"📝 <b>Редактирование описания поддержки</b>",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 <b>Редактирование описания поддержки</b>"),
|
||||
"",
|
||||
"Текущее описание:",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"),
|
||||
"",
|
||||
f"<code>{html.escape(current_desc_plain)}</code>",
|
||||
]
|
||||
if support_contact_display:
|
||||
text_parts += [
|
||||
"",
|
||||
"<b>Контакт для режима \u00abКонтакт\u00bb</b>",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "<b>Контакт для режима «Контакт»</b>"),
|
||||
f"<code>{html.escape(support_contact_display)}</code>",
|
||||
"",
|
||||
"Добавьте в описание при необходимости.",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."),
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_parts),
|
||||
@@ -364,24 +393,26 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.html_text or message.text
|
||||
SupportSettingsService.set_support_info_text(db_user.language, new_text)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer("✅ Описание обновлено.", reply_markup=markup)
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# send plain text for easy copying
|
||||
texts = get_texts(db_user.language)
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
# attach delete button to the sent message
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
if len(current_desc_plain) <= 4000:
|
||||
await callback.message.answer(current_desc_plain, reply_markup=markup)
|
||||
@@ -393,7 +424,7 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
is_last = (chunk + 4000) >= len(current_desc_plain)
|
||||
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
|
||||
chunk += 4000
|
||||
await callback.answer("Текст отправлен ниже")
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже"))
|
||||
|
||||
|
||||
@error_handler
|
||||
@@ -403,15 +434,15 @@ async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db:
|
||||
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
except Exception:
|
||||
may_delete = False
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
if not may_delete:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
try:
|
||||
await callback.message.delete()
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await callback.answer("Сообщение удалено")
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено"))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOG_PREVIEW_LIMIT = 2300
|
||||
|
||||
|
||||
def _resolve_log_path() -> Path:
|
||||
log_path = Path(settings.LOG_FILE)
|
||||
if not log_path.is_absolute():
|
||||
log_path = Path.cwd() / log_path
|
||||
return log_path
|
||||
|
||||
|
||||
def _format_preview_block(text: str) -> str:
|
||||
escaped_text = escape(text) if text else ""
|
||||
return f"<blockquote expandable><pre><code>{escaped_text}</code></pre></blockquote>"
|
||||
|
||||
|
||||
def _build_logs_message(log_path: Path) -> str:
|
||||
if not log_path.exists():
|
||||
message = (
|
||||
"🧾 <b>Системные логи</b>\n\n"
|
||||
f"Файл <code>{log_path}</code> пока не создан.\n"
|
||||
"Логи появятся автоматически после первой записи."
|
||||
)
|
||||
return message
|
||||
|
||||
try:
|
||||
content = log_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception as error: # pragma: no cover - защита от проблем чтения
|
||||
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
|
||||
message = (
|
||||
"❌ <b>Ошибка чтения логов</b>\n\n"
|
||||
f"Не удалось прочитать файл <code>{log_path}</code>."
|
||||
)
|
||||
return message
|
||||
|
||||
total_length = len(content)
|
||||
stats = log_path.stat()
|
||||
updated_at = datetime.fromtimestamp(stats.st_mtime)
|
||||
|
||||
if not content:
|
||||
preview_text = "Лог-файл пуст."
|
||||
truncated = False
|
||||
else:
|
||||
preview_text = content[-LOG_PREVIEW_LIMIT:]
|
||||
truncated = total_length > LOG_PREVIEW_LIMIT
|
||||
|
||||
details_lines = [
|
||||
"🧾 <b>Системные логи</b>",
|
||||
"",
|
||||
f"📁 <b>Файл:</b> <code>{log_path}</code>",
|
||||
f"🕒 <b>Обновлен:</b> {updated_at.strftime('%d.%m.%Y %H:%M:%S')}",
|
||||
f"🧮 <b>Размер:</b> {total_length} символов",
|
||||
(
|
||||
f"👇 Показаны последние {LOG_PREVIEW_LIMIT} символов."
|
||||
if truncated
|
||||
else "📄 Показано все содержимое файла."
|
||||
),
|
||||
"",
|
||||
_format_preview_block(preview_text),
|
||||
]
|
||||
|
||||
return "\n".join(details_lines)
|
||||
|
||||
|
||||
def _get_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_system_logs_refresh")],
|
||||
[InlineKeyboardButton(text="⬇️ Скачать лог", callback_data="admin_system_logs_download")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_system")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
message = _build_logs_message(log_path)
|
||||
|
||||
reply_markup = _get_logs_keyboard()
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def refresh_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
message = _build_logs_message(log_path)
|
||||
|
||||
reply_markup = _get_logs_keyboard()
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer("🔄 Обновлено")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def download_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
await callback.answer("❌ Лог-файл не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer("⬇️ Отправляю лог...")
|
||||
|
||||
document = FSInputFile(log_path)
|
||||
stats = log_path.stat()
|
||||
updated_at = datetime.fromtimestamp(stats.st_mtime).strftime("%d.%m.%Y %H:%M:%S")
|
||||
caption = (
|
||||
f"🧾 Лог-файл <code>{log_path.name}</code>\n"
|
||||
f"📁 Путь: <code>{log_path}</code>\n"
|
||||
f"🕒 Обновлен: {updated_at}"
|
||||
)
|
||||
await callback.message.answer_document(document=document, caption=caption, parse_mode="HTML")
|
||||
except Exception as error: # pragma: no cover - защита от ошибок отправки
|
||||
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
|
||||
await callback.message.answer(
|
||||
"❌ <b>Не удалось отправить лог-файл</b>\n\n"
|
||||
"Проверьте журналы приложения или повторите попытку позже.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_system_logs,
|
||||
F.data == "admin_system_logs",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
refresh_system_logs,
|
||||
F.data == "admin_system_logs_refresh",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
download_system_logs,
|
||||
F.data == "admin_system_logs_download",
|
||||
)
|
||||
@@ -79,12 +79,16 @@ async def show_admin_tickets(
|
||||
ticket_data = []
|
||||
for ticket in tickets:
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
username = ticket.user.username if ticket.user else None
|
||||
telegram_id = ticket.user.telegram_id if ticket.user else None
|
||||
ticket_data.append({
|
||||
'id': ticket.id,
|
||||
'title': ticket.title,
|
||||
'status_emoji': ticket.status_emoji,
|
||||
'priority_emoji': ticket.priority_emoji,
|
||||
'user_name': user_name,
|
||||
'username': username,
|
||||
'telegram_id': telegram_id,
|
||||
'is_closed': ticket.is_closed,
|
||||
'locked_emoji': ("🔒" if ticket.is_user_reply_blocked else "")
|
||||
})
|
||||
@@ -169,9 +173,13 @@ async def view_admin_ticket(
|
||||
}.get(ticket.status, ticket.status)
|
||||
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
|
||||
telegram_id_display = ticket.user.telegram_id if ticket.user else "—"
|
||||
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
|
||||
|
||||
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
|
||||
ticket_text += f"📱 Username: @{username_display}\n"
|
||||
ticket_text += f"📝 Заголовок: {ticket.title}\n"
|
||||
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
@@ -201,6 +209,33 @@ async def view_admin_ticket(
|
||||
db_user.language,
|
||||
is_user_blocked=ticket.is_user_reply_blocked
|
||||
)
|
||||
# Кнопка открытия профиля пользователя в админке
|
||||
try:
|
||||
if ticket.user:
|
||||
admin_profile_btn = types.InlineKeyboardButton(
|
||||
text="👤 К пользователю",
|
||||
callback_data=f"admin_user_manage_{ticket.user.id}_from_ticket_{ticket.id}"
|
||||
)
|
||||
keyboard.inline_keyboard.insert(0, [admin_profile_btn])
|
||||
except Exception:
|
||||
pass
|
||||
# Кнопки ЛС и профиль
|
||||
try:
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
buttons_row = []
|
||||
# DM: при наличии username используем tg://resolve, иначе fallback по ID
|
||||
if ticket.user.username:
|
||||
pm_url = f"tg://resolve?domain={ticket.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
# Профиль: по ID
|
||||
profile_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
if buttons_row:
|
||||
keyboard.inline_keyboard.insert(0, buttons_row)
|
||||
except Exception:
|
||||
pass
|
||||
if has_photos:
|
||||
try:
|
||||
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
|
||||
@@ -445,6 +480,17 @@ async def close_admin_ticket(
|
||||
# audit
|
||||
try:
|
||||
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
# обогатим details контактами пользователя тикета
|
||||
details = {}
|
||||
try:
|
||||
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
|
||||
if t and t.user:
|
||||
details.update({
|
||||
"target_telegram_id": t.user.telegram_id,
|
||||
"target_username": t.user.username,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
@@ -453,7 +499,7 @@ async def close_admin_ticket(
|
||||
action="close_ticket",
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=None,
|
||||
details={}
|
||||
details=details
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -645,6 +691,31 @@ async def handle_admin_block_duration_input(
|
||||
ticket_text += "📎 Вложение: фото\n\n"
|
||||
|
||||
kb = get_admin_ticket_view_keyboard(updated.id, updated.is_closed, db_user.language, is_user_blocked=updated.is_user_reply_blocked)
|
||||
# Кнопка открытия профиля пользователя в админке
|
||||
try:
|
||||
if updated.user:
|
||||
admin_profile_btn = types.InlineKeyboardButton(
|
||||
text="👤 К пользователю",
|
||||
callback_data=f"admin_user_manage_{updated.user.id}_from_ticket_{updated.id}"
|
||||
)
|
||||
kb.inline_keyboard.insert(0, [admin_profile_btn])
|
||||
except Exception:
|
||||
pass
|
||||
# Кнопки ЛС и профиль при обновлении карточки
|
||||
try:
|
||||
if updated.user and updated.user.telegram_id:
|
||||
buttons_row = []
|
||||
if updated.user.username:
|
||||
pm_url = f"tg://resolve?domain={updated.user.username}"
|
||||
else:
|
||||
pm_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
profile_url = f"tg://user?id={updated.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
if buttons_row:
|
||||
kb.inline_keyboard.insert(0, buttons_row)
|
||||
except Exception:
|
||||
pass
|
||||
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in updated.messages or [])
|
||||
if has_photos:
|
||||
try:
|
||||
@@ -699,6 +770,16 @@ async def unblock_user_in_ticket(
|
||||
try:
|
||||
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", ""))
|
||||
details = {}
|
||||
try:
|
||||
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
|
||||
if t and t.user:
|
||||
details.update({
|
||||
"target_telegram_id": t.user.telegram_id,
|
||||
"target_username": t.user.username,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
@@ -707,7 +788,7 @@ async def unblock_user_in_ticket(
|
||||
action="unblock_user",
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=None,
|
||||
details={}
|
||||
details=details
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -741,6 +822,16 @@ async def block_user_permanently(
|
||||
# audit
|
||||
try:
|
||||
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
details = {}
|
||||
try:
|
||||
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
|
||||
if t and t.user:
|
||||
details.update({
|
||||
"target_telegram_id": t.user.telegram_id,
|
||||
"target_username": t.user.username,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await TicketCRUD.add_support_audit(
|
||||
db,
|
||||
actor_user_id=db_user.id if db_user else None,
|
||||
@@ -749,7 +840,7 @@ async def block_user_permanently(
|
||||
action="block_user_perm",
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=None,
|
||||
details={}
|
||||
details=details
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+959
-99
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.states import AdminStates
|
||||
from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard
|
||||
@@ -44,16 +43,16 @@ async def show_welcome_text_panel(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👋 Управление приветственным текстом\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
|
||||
f"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -89,11 +88,11 @@ async def show_current_welcome_text(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
current_text = settings['text']
|
||||
is_enabled = settings['is_enabled']
|
||||
|
||||
if not settings['id']:
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
current_text = welcome_settings['text']
|
||||
is_enabled = welcome_settings['is_enabled']
|
||||
|
||||
if not welcome_settings['id']:
|
||||
status = "📝 Используется стандартный текст:"
|
||||
else:
|
||||
status = "📝 Текущий приветственный текст:"
|
||||
@@ -121,7 +120,7 @@ async def show_placeholders_help(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
@@ -137,7 +136,7 @@ async def show_placeholders_help(
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -149,12 +148,12 @@ async def show_formatting_help(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
formatting_info = get_telegram_formatting_info()
|
||||
|
||||
await callback.message.edit_text(
|
||||
formatting_info,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -167,8 +166,8 @@ async def start_edit_welcome_text(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
current_text = settings['text']
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
current_text = welcome_settings['text']
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
|
||||
@@ -206,9 +205,9 @@ async def process_welcome_text_edit(
|
||||
success = await set_welcome_text(db, new_text, db_user.id)
|
||||
|
||||
if success:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
|
||||
@@ -219,14 +218,14 @@ async def process_welcome_text_edit(
|
||||
f"Новый текст:\n"
|
||||
f"<code>{new_text}</code>\n\n"
|
||||
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
await message.answer(
|
||||
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
@@ -242,9 +241,9 @@ async def reset_welcome_text(
|
||||
success = await set_welcome_text(db, default_text, db_user.id)
|
||||
|
||||
if success:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Приветственный текст сброшен на стандартный!\n\n"
|
||||
@@ -252,14 +251,14 @@ async def reset_welcome_text(
|
||||
f"Стандартный текст:\n"
|
||||
f"<code>{default_text}</code>\n\n"
|
||||
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@@ -281,14 +280,14 @@ async def show_preview_welcome_text(
|
||||
test_user = TestUser()
|
||||
preview_text = await get_welcome_text_for_user(db, test_user)
|
||||
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
|
||||
if preview_text:
|
||||
await callback.message.edit_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
|
||||
f"<code>{preview_text}</code>",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
@@ -296,7 +295,7 @@ async def show_preview_welcome_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"🔴 Приветственные сообщения отключены.\n"
|
||||
f"Новые пользователи не будут получать приветственный текст после регистрации.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
+166
-37
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -386,9 +387,9 @@ async def start_pal24_payment(
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через PayPalych</b>\n\n"
|
||||
"🏦 <b>Оплата через PayPalych (СБП)</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
"Оплата проходит через защищенную платформу PayPalych."
|
||||
"Оплата проходит через систему быстрых платежей PayPalych."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1002,7 +1003,7 @@ async def process_pal24_payment_amount(
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("link_url"):
|
||||
if not payment_result:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
@@ -1012,46 +1013,146 @@ async def process_pal24_payment_amount(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
link_url = payment_result.get("link_url")
|
||||
sbp_url = (
|
||||
payment_result.get("sbp_url")
|
||||
or payment_result.get("transfer_url")
|
||||
)
|
||||
card_url = payment_result.get("card_url")
|
||||
fallback_url = (
|
||||
payment_result.get("link_page_url")
|
||||
or payment_result.get("link_url")
|
||||
)
|
||||
|
||||
if not (sbp_url or card_url or fallback_url):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not sbp_url:
|
||||
sbp_url = fallback_url
|
||||
|
||||
bill_id = payment_result.get("bill_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAL24_PAY_BUTTON", "💳 Оплатить через PayPalych"),
|
||||
url=link_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
pay_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
steps: list[str] = []
|
||||
step_counter = 1
|
||||
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_url:
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_url and card_url != sbp_url:
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(card_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
if not pay_buttons and fallback_url:
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=fallback_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
follow_template = texts.t(
|
||||
"PAL24_INSTRUCTION_FOLLOW",
|
||||
"{step}. Следуйте подсказкам платёжной системы",
|
||||
)
|
||||
steps.append(follow_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
confirm_template = texts.t(
|
||||
"PAL24_INSTRUCTION_CONFIRM",
|
||||
"{step}. Подтвердите перевод",
|
||||
)
|
||||
steps.append(confirm_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
success_template = texts.t(
|
||||
"PAL24_INSTRUCTION_COMPLETE",
|
||||
"{step}. Средства зачислятся автоматически",
|
||||
)
|
||||
steps.append(success_template.format(step=step_counter))
|
||||
|
||||
message_template = texts.t(
|
||||
"PAL24_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через PayPalych</b>\n\n"
|
||||
"🏦 <b>Оплата через PayPalych</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID счета: {bill_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку ‘Оплатить через PayPalych’\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"📱 <b>Инструкция:</b>\n{steps}\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard_rows = pay_buttons + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
bill_id=bill_id,
|
||||
steps="\n".join(steps),
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
@@ -1226,26 +1327,48 @@ async def check_pal24_payment_status(
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
metadata = payment.metadata_json or {}
|
||||
links_meta = metadata.get("links") if isinstance(metadata, dict) else None
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
|
||||
message_lines = [
|
||||
"💳 Статус платежа PayPalych:\n\n",
|
||||
f"🆔 ID счета: {payment.bill_id}\n",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
|
||||
f"📊 Статус: {emoji} {status_text}\n",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
|
||||
"🏦 Статус платежа PayPalych:",
|
||||
"",
|
||||
f"🆔 ID счета: {payment.bill_id}",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
|
||||
f"📊 Статус: {emoji} {status_text}",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
message_lines.append("")
|
||||
message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"NEW", "PROCESS"}:
|
||||
message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
|
||||
if payment.link_url:
|
||||
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.link_url}")
|
||||
message_lines.append("")
|
||||
message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
|
||||
if sbp_link:
|
||||
message_lines.append("")
|
||||
message_lines.append(f"🏦 СБП: {sbp_link}")
|
||||
if card_link and card_link != sbp_link:
|
||||
message_lines.append(f"💳 Банковская карта: {card_link}")
|
||||
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
|
||||
message_lines.append("")
|
||||
message_lines.append(
|
||||
f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
|
||||
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer("".join(message_lines), show_alert=True)
|
||||
await callback.answer()
|
||||
await callback.message.answer(
|
||||
"\n".join(message_lines),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
|
||||
@@ -1528,6 +1651,12 @@ async def handle_quick_amount_selection(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
+119
-23
@@ -1,15 +1,15 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_keyboard
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.database.models import User
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
@@ -24,11 +24,12 @@ logger = logging.getLogger(__name__)
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
skip_callback_answer: bool = False,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from datetime import datetime
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
@@ -43,13 +44,18 @@ async def show_main_menu(
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
is_admin = settings.is_admin(db_user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)),
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
@@ -59,23 +65,13 @@ async def show_main_menu(
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user: User
|
||||
) -> None:
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
|
||||
if not skip_callback_answer:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
@@ -95,6 +91,90 @@ async def show_service_rules(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_language_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("LANGUAGE_PROMPT", "🌐 Выберите язык интерфейса:"),
|
||||
keyboard=get_language_selection_keyboard(
|
||||
current_language=db_user.language,
|
||||
include_back=True,
|
||||
language=db_user.language,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_language_change(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
selected_raw = (callback.data or "").split(":", 1)[-1]
|
||||
normalized_selected = selected_raw.strip().lower()
|
||||
|
||||
available_map = {
|
||||
lang.strip().lower(): lang.strip()
|
||||
for lang in settings.get_available_languages()
|
||||
if isinstance(lang, str) and lang.strip()
|
||||
}
|
||||
|
||||
if normalized_selected not in available_map:
|
||||
await callback.answer("❌ Unsupported language", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_language = available_map[normalized_selected].lower()
|
||||
|
||||
if db_user.language.lower() == normalized_selected:
|
||||
await show_main_menu(
|
||||
callback,
|
||||
db_user,
|
||||
db,
|
||||
skip_callback_answer=True,
|
||||
)
|
||||
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
|
||||
return
|
||||
|
||||
updated_user = await update_user(db, db_user, language=resolved_language)
|
||||
texts = get_texts(updated_user.language)
|
||||
|
||||
await show_main_menu(
|
||||
callback,
|
||||
updated_user,
|
||||
db,
|
||||
skip_callback_answer=True,
|
||||
)
|
||||
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
|
||||
|
||||
|
||||
async def handle_back_to_menu(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -116,13 +196,18 @@ async def handle_back_to_menu(
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
is_admin = settings.is_admin(db_user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)),
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
@@ -240,3 +325,14 @@ def register_handlers(dp: Dispatcher):
|
||||
show_service_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_language_menu,
|
||||
F.data == "menu_language"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_change,
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(None)
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,6 +62,21 @@ async def process_promocode(
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
db_user,
|
||||
result.get("promocode", {"code": code}),
|
||||
result["description"],
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления об активации промокода %s: %s",
|
||||
code,
|
||||
notify_error,
|
||||
)
|
||||
else:
|
||||
error_messages = {
|
||||
"not_found": texts.PROMOCODE_INVALID,
|
||||
|
||||
+280
-63
@@ -19,13 +19,18 @@ from app.database.crud.campaign import (
|
||||
)
|
||||
from app.database.models import UserStatus, SubscriptionStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
|
||||
get_rules_keyboard,
|
||||
get_main_menu_keyboard,
|
||||
get_post_registration_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
)
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
|
||||
@@ -72,8 +77,8 @@ async def _apply_campaign_bonus_if_needed(
|
||||
|
||||
|
||||
async def handle_potential_referral_code(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
@@ -133,7 +138,80 @@ async def handle_potential_referral_code(
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _get_language_prompt_text() -> str:
|
||||
return "🌐 Выберите язык / Choose your language:"
|
||||
|
||||
|
||||
async def _prompt_language_selection(message: types.Message, state: FSMContext) -> None:
|
||||
logger.info(f"🌐 LANGUAGE: Запрос выбора языка для пользователя {message.from_user.id}")
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_language)
|
||||
await message.answer(
|
||||
_get_language_prompt_text(),
|
||||
reply_markup=get_language_selection_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
async def _continue_registration_after_language(
|
||||
*,
|
||||
message: types.Message | None,
|
||||
callback: types.CallbackQuery | None,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
target_message = callback.message if callback else message
|
||||
if not target_message:
|
||||
logger.warning("⚠️ LANGUAGE: Нет доступного сообщения для продолжения регистрации")
|
||||
return
|
||||
|
||||
async def _complete_registration_wrapper():
|
||||
if callback:
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ LANGUAGE: SKIP_RULES_ACCEPT включен - пропускаем правила")
|
||||
|
||||
if data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ LANGUAGE: Реферер найден: {referrer.id}")
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
await _complete_registration_wrapper()
|
||||
else:
|
||||
try:
|
||||
await target_message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info("🔍 LANGUAGE: Ожидание ввода реферального кода")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде после выбора языка: {error}")
|
||||
await _complete_registration_wrapper()
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await target_message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 LANGUAGE: Правила отправлены после выбора языка")
|
||||
|
||||
|
||||
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
|
||||
@@ -165,6 +243,22 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
await state.update_data(referral_code=referral_code)
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if campaign:
|
||||
try:
|
||||
notification_service = AdminNotificationService(message.bot)
|
||||
await notification_service.send_campaign_link_visit_notification(
|
||||
db,
|
||||
message.from_user,
|
||||
campaign,
|
||||
user,
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ уведомления о переходе по кампании %s: %s",
|
||||
campaign.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
|
||||
@@ -229,17 +323,23 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
|
||||
user.telegram_id
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -309,55 +409,128 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
else:
|
||||
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
logger.info(f"💾 Установлен язык по умолчанию: {language}")
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
|
||||
if data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
if not data.get('language'):
|
||||
if settings.is_language_selection_enabled():
|
||||
await _prompt_language_selection(message, state)
|
||||
return
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
await complete_registration(message, state, db)
|
||||
else:
|
||||
try:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
default_language = (
|
||||
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
|
||||
if isinstance(settings.DEFAULT_LANGUAGE, str)
|
||||
else DEFAULT_LANGUAGE
|
||||
)
|
||||
normalized_default = default_language.split("-")[0].lower()
|
||||
data['language'] = normalized_default
|
||||
await state.set_data(data)
|
||||
logger.info(
|
||||
"🌐 LANGUAGE: выбор языка отключен, устанавливаем язык по умолчанию '%s'",
|
||||
normalized_default,
|
||||
)
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=message,
|
||||
callback=None,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def process_language_selection(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
logger.info(
|
||||
f"🌐 LANGUAGE: Пользователь {callback.from_user.id} выбрал язык ({callback.data})"
|
||||
)
|
||||
|
||||
if not settings.is_language_selection_enabled():
|
||||
data = await state.get_data() or {}
|
||||
default_language = (
|
||||
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
|
||||
if isinstance(settings.DEFAULT_LANGUAGE, str)
|
||||
else DEFAULT_LANGUAGE
|
||||
)
|
||||
normalized_default = default_language.split("-")[0].lower()
|
||||
data['language'] = normalized_default
|
||||
await state.set_data(data)
|
||||
|
||||
texts = get_texts(normalized_default)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info("🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration(message, state, db)
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
texts.t(
|
||||
"LANGUAGE_SELECTION_DISABLED",
|
||||
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=None,
|
||||
callback=callback,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"📋 Правила отправлены")
|
||||
selected_raw = (callback.data or "").split(":", 1)[-1]
|
||||
normalized_selected = selected_raw.strip().lower()
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Установлено состояние: {current_state}")
|
||||
available_map = {
|
||||
lang.strip().lower(): lang.strip()
|
||||
for lang in settings.get_available_languages()
|
||||
if isinstance(lang, str) and lang.strip()
|
||||
}
|
||||
|
||||
if normalized_selected not in available_map:
|
||||
logger.warning(
|
||||
f"⚠️ LANGUAGE: Выбран недоступный язык '{normalized_selected}' пользователем {callback.from_user.id}"
|
||||
)
|
||||
await callback.answer("❌ Unsupported language", show_alert=True)
|
||||
return
|
||||
|
||||
resolved_language = available_map[normalized_selected].lower()
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = resolved_language
|
||||
await state.set_data(data)
|
||||
|
||||
texts = get_texts(resolved_language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка: {error}")
|
||||
await callback.message.answer(
|
||||
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await _continue_registration_after_language(
|
||||
message=None,
|
||||
callback=callback,
|
||||
state=state,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
async def process_rules_accept(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
@@ -562,18 +735,25 @@ async def complete_registration_from_callback(
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
|
||||
is_admin = settings.is_admin(existing_user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(existing_user.telegram_id)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -727,18 +907,25 @@ async def complete_registration_from_callback(
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -786,18 +973,25 @@ async def complete_registration(
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
|
||||
is_admin = settings.is_admin(existing_user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(existing_user.telegram_id)
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -951,18 +1145,25 @@ async def complete_registration(
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -1070,8 +1271,9 @@ def get_referral_code_keyboard(language: str):
|
||||
|
||||
async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
import html
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
user_name=html.escape(user.full_name or ""),
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
@@ -1089,8 +1291,9 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
|
||||
import html
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
user_name=html.escape(user_name or ""),
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
)
|
||||
|
||||
@@ -1189,14 +1392,21 @@ async def required_sub_channel_check(
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (
|
||||
(not is_admin)
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
keyboard = get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
is_admin=is_admin,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
@@ -1291,7 +1501,14 @@ def register_handlers(dp: Dispatcher):
|
||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
process_language_selection,
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(RegistrationStates.waiting_for_language)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_language_selection")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_referral_code_skip,
|
||||
F.data == "referral_skip",
|
||||
|
||||
+1615
-1200
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User, Ticket, TicketStatus
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.keyboards.inline import (
|
||||
get_ticket_cancel_keyboard,
|
||||
get_my_tickets_keyboard,
|
||||
@@ -411,7 +412,6 @@ async def show_my_tickets(
|
||||
# Добавим кнопку перехода к закрытым
|
||||
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")])
|
||||
# Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"),
|
||||
@@ -455,7 +455,6 @@ async def show_my_tickets_closed(
|
||||
data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets]
|
||||
kb = get_my_tickets_keyboard(data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_closed_page_")
|
||||
kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")])
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"),
|
||||
@@ -937,10 +936,21 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
if len(title) > 60:
|
||||
title = title[:57] + "..."
|
||||
|
||||
# Загрузим пользователя, чтобы отобразить реальный Telegram ID и username
|
||||
try:
|
||||
user = await get_user_by_id(db, ticket.user_id)
|
||||
except Exception:
|
||||
user = None
|
||||
full_name = user.full_name if user else "Unknown"
|
||||
telegram_id_display = user.telegram_id if user else "—"
|
||||
username_display = (user.username or "отсутствует") if user else "отсутствует"
|
||||
|
||||
notification_text = (
|
||||
f"🎫 <b>НОВЫЙ ТИКЕТ</b>\n\n"
|
||||
f"🆔 <b>ID:</b> <code>{ticket.id}</code>\n"
|
||||
f"👤 <b>User ID:</b> <code>{ticket.user_id}</code>\n"
|
||||
f"👤 <b>Пользователь:</b> {full_name}\n"
|
||||
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
|
||||
f"📱 <b>Username:</b> @{username_display}\n"
|
||||
f"📝 <b>Заголовок:</b> {title or '—'}\n"
|
||||
f"📅 <b>Создан:</b> {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
)
|
||||
|
||||
+1006
-317
File diff suppressed because it is too large
Load Diff
+83
-15
@@ -17,6 +17,11 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LANGUAGE_DISPLAY_NAMES = {
|
||||
"ru": "🇷🇺 Русский",
|
||||
"en": "🇬🇧 English",
|
||||
}
|
||||
|
||||
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -62,6 +67,49 @@ def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKe
|
||||
])
|
||||
|
||||
|
||||
def get_language_selection_keyboard(
|
||||
current_language: Optional[str] = None,
|
||||
*,
|
||||
include_back: bool = False,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
) -> InlineKeyboardMarkup:
|
||||
available_languages = settings.get_available_languages()
|
||||
|
||||
buttons: List[List[InlineKeyboardButton]] = []
|
||||
row: List[InlineKeyboardButton] = []
|
||||
|
||||
normalized_current = (current_language or "").lower()
|
||||
|
||||
for index, lang_code in enumerate(available_languages, start=1):
|
||||
normalized_code = lang_code.lower()
|
||||
display_name = _LANGUAGE_DISPLAY_NAMES.get(
|
||||
normalized_code,
|
||||
normalized_code.upper(),
|
||||
)
|
||||
|
||||
prefix = "✅ " if normalized_code == normalized_current and normalized_current else ""
|
||||
|
||||
row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{prefix}{display_name}",
|
||||
callback_data=f"language_select:{normalized_code}",
|
||||
)
|
||||
)
|
||||
|
||||
if len(row) == 2:
|
||||
buttons.append(row)
|
||||
row = []
|
||||
|
||||
if row:
|
||||
buttons.append(row)
|
||||
|
||||
if include_back:
|
||||
texts = get_texts(language)
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_main_menu_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
is_admin: bool = False,
|
||||
@@ -224,6 +272,11 @@ def get_main_menu_keyboard(
|
||||
support_row.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"))
|
||||
support_row.append(InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules"))
|
||||
keyboard.append(support_row)
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
|
||||
])
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
|
||||
|
||||
@@ -392,28 +445,31 @@ def get_insufficient_balance_keyboard(
|
||||
texts = get_texts(language)
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks or 0, language)
|
||||
|
||||
if resume_callback:
|
||||
keyboard.inline_keyboard.insert(
|
||||
0,
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
],
|
||||
)
|
||||
back_row_index: int | None = None
|
||||
|
||||
if keyboard.inline_keyboard:
|
||||
last_row = keyboard.inline_keyboard[-1]
|
||||
if (
|
||||
len(last_row) == 1
|
||||
and isinstance(last_row[0], InlineKeyboardButton)
|
||||
and last_row[0].callback_data == "menu_balance"
|
||||
and last_row[0].callback_data in {"menu_balance", "back_to_menu"}
|
||||
):
|
||||
keyboard.inline_keyboard[-1][0] = InlineKeyboardButton(
|
||||
text=last_row[0].text,
|
||||
text=texts.t("PAYMENT_RETURN_HOME_BUTTON", "🏠 На главную"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
back_row_index = len(keyboard.inline_keyboard) - 1
|
||||
|
||||
if resume_callback:
|
||||
return_row = [
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data=resume_callback,
|
||||
)
|
||||
]
|
||||
|
||||
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
|
||||
keyboard.inline_keyboard.insert(insert_index, return_row)
|
||||
|
||||
return keyboard
|
||||
|
||||
@@ -829,7 +885,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)"),
|
||||
text=texts.t("PAYMENT_CARD_PAL24", "🏦 СБП (PayPalych)"),
|
||||
callback_data=_build_callback("pal24")
|
||||
)
|
||||
])
|
||||
@@ -1922,10 +1978,22 @@ def get_admin_tickets_keyboard(
|
||||
status_emoji = ticket.get('status_emoji', '❓')
|
||||
if ticket.get('is_closed', False):
|
||||
status_emoji = '✅'
|
||||
user_name = ticket.get('user_name', 'Unknown')[:15]
|
||||
user_name = ticket.get('user_name', 'Unknown')
|
||||
username = ticket.get('username')
|
||||
telegram_id = ticket.get('telegram_id')
|
||||
# Сформируем компактное отображение: Имя (@username | ID)
|
||||
name_parts = [user_name[:15]]
|
||||
contact_parts = []
|
||||
if username:
|
||||
contact_parts.append(f"@{username}")
|
||||
if telegram_id:
|
||||
contact_parts.append(str(telegram_id))
|
||||
if contact_parts:
|
||||
name_parts.append(f"({' | '.join(contact_parts)})")
|
||||
name_display = ' '.join(name_parts)
|
||||
title = ticket.get('title', 'Без названия')[:20]
|
||||
locked_emoji = ticket.get('locked_emoji', '')
|
||||
button_text = f"{status_emoji} #{ticket['id']} {locked_emoji} {user_name}: {title}".replace(" ", " ")
|
||||
button_text = f"{status_emoji} #{ticket['id']} {locked_emoji} {name_display}: {title}".replace(" ", " ")
|
||||
row = [InlineKeyboardButton(text=button_text, callback_data=f"admin_view_ticket_{ticket['id']}")]
|
||||
if ticket.get('is_closed', False):
|
||||
closed_rows.append(row)
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
{
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Server management is unavailable — only one server is accessible",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Manage subscription countries</b>\n\n📋 <b>Current countries ({current_count}):</b>\n{current_list}\n\n💡 <b>How it works:</b>\n✅ — currently connected\n➕ — will be added (paid)\n➖ — will be removed (free)\n⚪ — not selected\n\n⚠️ <b>Important:</b> Reconnecting removed countries will be charged again!",
|
||||
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
|
||||
"PAID_FEATURE_ONLY": "⚠ This feature is available only for paid subscriptions",
|
||||
"PAID_FEATURE_ONLY_SHORT": "⚠ Paid subscriptions only",
|
||||
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ This server is not available for your promo group",
|
||||
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ No changes detected",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
|
||||
"COUNTRY_CHANGES_ADDED_HEADER": "➕ <b>Added countries:</b>\n",
|
||||
"COUNTRY_CHANGES_CHARGED": "💰 Charged: {amount} (for {months} mo)",
|
||||
"COUNTRY_CHANGES_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Removed countries:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Reconnecting later will be charged",
|
||||
"COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 <b>Active countries:</b> {count}",
|
||||
"ADMIN_MAIN_MENU": "🏠 Main menu",
|
||||
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
|
||||
"AUTOPAY_BUTTON": "💳 Auto payment",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
|
||||
"AUTOPAY_STATUS_ENABLED": "enabled",
|
||||
"AUTOPAY_STATUS_DISABLED": "disabled",
|
||||
"AUTOPAY_MENU_TEXT": "💳 <b>Auto payment</b>\n\n📊 <b>Status:</b> {status}\n⏰ <b>Charge:</b> {days} days before expiry\n\nChoose an action:",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
|
||||
"AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Choose how many days before expiry to charge the payment:",
|
||||
"AUTOPAY_DAYS_SET": "✅ Set to {days} days!",
|
||||
"BACK": "⬅️ Back",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
|
||||
"CANCEL": "❌ Cancel",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
|
||||
"CHECK_STATUS_BUTTON": "📊 Check status",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
|
||||
"CONFIRM": "✅ Confirm",
|
||||
@@ -48,12 +70,33 @@
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
|
||||
"LANGUAGE_PROMPT": "🌐 Choose the interface language:",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"DEVICE_UUID_NOT_FOUND": "❌ User UUID not found",
|
||||
"DEVICE_NONE_CONNECTED": "ℹ️ You have no connected devices",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
|
||||
"DEVICE_MANAGEMENT_OVERVIEW": "🔄 <b>Device management</b>\n\n📊 Total connected: {total} devices\n📄 Page {page} of {pages}\n\n",
|
||||
"DEVICE_MANAGEMENT_CONNECTED_HEADER": "<b>Connected devices:</b>\n",
|
||||
"DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
|
||||
"DEVICE_MANAGEMENT_ACTIONS": "\n💡 <b>Actions:</b>\n• Select a device to reset\n• Or reset all devices at once",
|
||||
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
|
||||
"DEVICE_PAGE_LOAD_ERROR": "❌ Failed to open the page",
|
||||
"DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request",
|
||||
"DEVICE_RESET_PARSE_ERROR": "❌ Failed to process the request",
|
||||
"DEVICE_RESET_SUCCESS": "✅ Device {device} has been reset!",
|
||||
"DEVICE_RESET_ALL_DONE": "ℹ️ All devices have been reset",
|
||||
"DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID",
|
||||
"DEVICE_RESET_NOT_FOUND": "❌ Device not found",
|
||||
"DEVICE_RESET_ERROR": "❌ Failed to reset the device",
|
||||
"DEVICE_LIST_FETCH_ERROR": "❌ Failed to load device list",
|
||||
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>All devices have been reset!</b>\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect",
|
||||
"DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ <b>Devices reset partially</b>\n\n✅ Removed: {success} devices\n❌ Failed to remove: {failed} devices\n\nTry again or contact support.",
|
||||
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Couldn't reset devices</b>\n\nPlease try again later or contact support.\n\nTotal devices: {total}",
|
||||
"MENU_BALANCE": "💰 Balance",
|
||||
"MENU_SUBSCRIPTION": "📱 Subscription",
|
||||
"MENU_TRIAL": "🎁 Trial subscription",
|
||||
@@ -96,6 +139,9 @@
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link",
|
||||
"SKIP_BUTTON": "Skip ➡️",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
|
||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Subscription settings</b>\n\n📊 <b>Current parameters:</b>\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
|
||||
"SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ You don't have an active subscription!",
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days",
|
||||
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
|
||||
@@ -161,6 +207,22 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
|
||||
"ADMIN_USER_MANAGEMENT_PROFILE": "👤 <b>User management</b>\n\n<b>Main information:</b>\n• Name: {name}\n• ID: <code>{telegram_id}</code>\n• Username: {username}\n• Status: {status}\n• Language: {language}\n\n<b>Finances:</b>\n• Balance: {balance}\n• Transactions: {transactions}\n\n<b>Activity:</b>\n• Registration: {registration}\n• Last activity: {last_activity}\n• Days since registration: {registration_days}",
|
||||
"ADMIN_USER_USERNAME_NOT_SET": "not set",
|
||||
"ADMIN_USER_STATUS_ACTIVE": "✅ Active",
|
||||
"ADMIN_USER_STATUS_BLOCKED": "🚫 Blocked",
|
||||
"ADMIN_USER_STATUS_DELETED": "🗑️ Deleted",
|
||||
"ADMIN_USER_STATUS_UNKNOWN": "❓ Unknown",
|
||||
"ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Unknown",
|
||||
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Trial",
|
||||
"ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Paid",
|
||||
"ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Active",
|
||||
"ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Inactive",
|
||||
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} GB",
|
||||
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "<b>Subscription:</b>\n• Type: {type}\n• Status: {status}\n• Until: {end_date}\n• Traffic: {traffic}\n• Devices: {devices}\n• Countries: {countries}",
|
||||
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Subscription:</b> None",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Promo group:</b>\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Promo group:</b> Not assigned",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Promo group:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
|
||||
@@ -218,6 +280,19 @@
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
|
||||
"DEVICE_CHANGE_ACTION_INCREASE": "increase to {count}",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_FREE": "Free",
|
||||
"DEVICE_CHANGE_ACTION_DECREASE": "decrease to {count}",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Confirm change</b>\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n",
|
||||
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
|
||||
"DEVICE_CHANGE_CHARGED": "💰 Charged: {amount}",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Payments are not refunded",
|
||||
"INVALID_AMOUNT": "❌ Invalid amount",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n",
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n",
|
||||
@@ -298,6 +373,9 @@
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
|
||||
"USER_NOT_FOUND": "❌ User not found",
|
||||
"MENU_LANGUAGE": "🌐 Language",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
|
||||
@@ -330,7 +408,9 @@
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ The subscription link is available via the buttons below or in the “My subscription” section.",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\nℹ️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
@@ -412,6 +492,7 @@
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
@@ -422,6 +503,200 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
|
||||
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
|
||||
"ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽",
|
||||
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
|
||||
"ADMIN_REPORTS": "📊 Reports",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
|
||||
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Closed support tickets:",
|
||||
"ADMIN_TICKETS_TITLE_OPEN": "🎫 Open support tickets:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Enter support reply:",
|
||||
"ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!",
|
||||
"ATTACHMENTS_SENT": "✅ Attachments sent.",
|
||||
"BACK_TO_MENU": "🏠 Back to menu",
|
||||
"BACK_TO_OPEN_TICKETS": "🔴 Open tickets",
|
||||
"BACK_TO_SUPPORT": "⬅️ Back to support",
|
||||
"BACK_TO_TICKETS": "⬅️ Back to tickets",
|
||||
"BALANCE_TOPUP": "💳 Top up balance",
|
||||
"BLOCK_BY_TIME": "⏳ Temporary block",
|
||||
"BLOCK_FOREVER": "🚫 Block permanently",
|
||||
"CAMPAIGN_EXISTING_USERL": "ℹ️ This promotional link is available to new users only.",
|
||||
"CANCEL_REPLY": "❌ Cancel reply",
|
||||
"CANCEL_TICKET_CREATION": "❌ Cancel ticket creation",
|
||||
"CLOSED_TICKETS": "🟢 Closed",
|
||||
"CLOSED_TICKETS_HEADER": "🟢 Closed tickets",
|
||||
"CLOSED_TICKETS_TITLE": "🟢 Closed tickets:",
|
||||
"CLOSE_NOTIFICATION": "❌ Close notification",
|
||||
"CLOSE_TICKET": "🔒 Close ticket",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Contact support",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
|
||||
"DELETE_MESSAGE": "🗑 Delete",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
|
||||
"ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):",
|
||||
"LANGUAGE_SELECTION_DISABLED": "⚙️ Language selection is temporarily unavailable. Using the default language.",
|
||||
"MARK_AS_ANSWERED": "✅ Mark as answered",
|
||||
"MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.",
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Mulen Pay payment</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay",
|
||||
"MULENPAY_TOPUP_PROMPT": "💳 <b>Mulen Pay payment</b>\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.",
|
||||
"MY_TICKETS_BUTTON": "📋 My tickets",
|
||||
"MY_TICKETS_TITLE": "📋 Your tickets:",
|
||||
"NOTIFICATION_CLOSED": "Notification closed.",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):",
|
||||
"NO_ATTACHMENTS": "No attachments.",
|
||||
"NO_CLOSED_TICKETS": "There are no closed tickets yet.",
|
||||
"NO_TICKETS": "You don't have any tickets yet.",
|
||||
"NO_TICKETS_ADMIN": "No tickets to display.",
|
||||
"OPEN_TICKETS": "🔴 Open",
|
||||
"OPEN_TICKETS_HEADER": "🔴 Open tickets",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
|
||||
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
|
||||
"PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
|
||||
"REPLY_TO_TICKET": "💬 Reply",
|
||||
"REPORT_CLOSE": "❌ Close",
|
||||
"REPORT_CLOSED": "✅ Report closed.",
|
||||
"REPORT_CLOSE_ERROR": "❌ Failed to close the report.",
|
||||
"SENDING_ATTACHMENTS": "📎 Sending attachments...",
|
||||
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.",
|
||||
"SUBSCRIPTION_EXTEND": "💎 Extend subscription",
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",
|
||||
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.",
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually:",
|
||||
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Open link in Happ</a>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Connect via Happ</b>",
|
||||
"SUPPORT_BUTTON": "🆘 Support",
|
||||
"TICKET_ALREADY_OPEN": "You already have an open ticket. Please close it first.",
|
||||
"TICKET_ATTACHMENTS": "📎 Attachments",
|
||||
"TICKET_CLOSED": "✅ Ticket closed.",
|
||||
"TICKET_CLOSED_NO_REPLY": "❌ The ticket is closed; replying is not possible.",
|
||||
"TICKET_CLOSE_ERROR": "❌ Error closing ticket.",
|
||||
"TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.",
|
||||
"TICKET_CREATE_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
|
||||
"TICKET_CREATION_CANCELLED": "Ticket creation cancelled.",
|
||||
"TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
|
||||
"TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.",
|
||||
"TICKET_MESSAGE_INPUT": "Now describe your problem or question:",
|
||||
"TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:",
|
||||
"TICKET_NOT_FOUND": "Ticket not found.",
|
||||
"TICKET_PRIORITY_HIGH": "🟠 High",
|
||||
"TICKET_PRIORITY_LOW": "🟢 Low",
|
||||
"TICKET_PRIORITY_NORMAL": "🟡 Normal",
|
||||
"TICKET_PRIORITY_SELECT": "Select ticket priority:",
|
||||
"TICKET_PRIORITY_URGENT": "🔴 Urgent",
|
||||
"TICKET_REPLY_CANCELLED": "Reply cancelled.",
|
||||
"TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.",
|
||||
"TICKET_REPLY_INPUT": "Enter your reply:",
|
||||
"TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:",
|
||||
"TICKET_REPLY_SENT": "✅ Your reply has been sent!",
|
||||
"TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:",
|
||||
"TICKET_STATUS_ANSWERED": "Answered",
|
||||
"TICKET_STATUS_CLOSED": "Closed",
|
||||
"TICKET_STATUS_OPEN": "Open",
|
||||
"TICKET_STATUS_PENDING": "Pending",
|
||||
"TICKET_TITLE_INPUT": "Enter ticket title:",
|
||||
"TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:",
|
||||
"TICKET_UPDATE_ERROR": "❌ Error updating ticket.",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
|
||||
"UNBLOCK": "✅ Unblock",
|
||||
"USER_BLOCKED_FOREVER": "You are blocked from contacting support.",
|
||||
"USER_BLOCKED_UNTIL": "You are blocked until {time}",
|
||||
"VIEW_CLOSED_TICKETS": "🟢 Closed tickets",
|
||||
"VIEW_TICKET": "👁️ View ticket",
|
||||
"ADMIN_USERS_SUBMENU_TITLE": "👥 **User and subscription management**\n\n",
|
||||
"ADMIN_PROMO_SUBMENU_TITLE": "💰 **Promo codes and statistics**\n\n",
|
||||
"ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Communications**\n\n",
|
||||
"ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Manage broadcasts and interface texts:",
|
||||
"ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:",
|
||||
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Support**\n\n",
|
||||
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Manage tickets and support settings:",
|
||||
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Ticket access only.",
|
||||
"ADMIN_SUPPORT_MODERATION_TITLE": "🧑⚖️ <b>Support moderation</b>",
|
||||
"ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Access to support tickets.",
|
||||
"ADMIN_SUPPORT_AUDIT_TITLE": "🧾 <b>Moderator audit</b>",
|
||||
"ADMIN_SUPPORT_AUDIT_EMPTY": "Nothing here yet",
|
||||
"ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Moderator",
|
||||
"ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Admin",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Ticket closed",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Timed block",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Permanent block",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
|
||||
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Enabled",
|
||||
"ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Disabled",
|
||||
"ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "\"Support\" menu item",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Tickets",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Contact",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Both",
|
||||
"ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Edit description",
|
||||
"ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Admin notifications",
|
||||
"ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "User notifications",
|
||||
"ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
|
||||
"ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ SLA time: {minutes} min",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑⚖️ Moderators: {count}",
|
||||
"ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Assign moderator",
|
||||
"ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Remove moderator",
|
||||
"ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 <b>Support settings</b>",
|
||||
"ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Working hours and menu visibility. Current support menu description:",
|
||||
"ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ <b>SLA configuration</b>\n\nEnter the response wait time in minutes (integer > 0):",
|
||||
"ADMIN_SUPPORT_SLA_INVALID": "❌ Enter a valid number of minutes (1-1440)",
|
||||
"ADMIN_SUPPORT_SLA_SAVED": "✅ SLA value saved",
|
||||
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑⚖️ <b>Assign moderator</b>\n\nSend the user's Telegram ID (number)",
|
||||
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑⚖️ <b>Remove moderator</b>\n\nSend the user's Telegram ID (number)",
|
||||
"ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Enter a valid Telegram ID (number)",
|
||||
"ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Moderator {tid} removed",
|
||||
"ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Failed to remove moderator",
|
||||
"ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ User {tid} assigned as moderator",
|
||||
"ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Failed to assign moderator",
|
||||
"ADMIN_SUPPORT_MODERATORS_EMPTY": "List is empty",
|
||||
"ADMIN_SUPPORT_MODERATORS_TITLE": "🧑⚖️ <b>Moderators</b>",
|
||||
"ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Send description",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 <b>Editing support description</b>",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Current description:",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "<b>Contact for \"Contact\" mode</b>",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below",
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
{
|
||||
"ACCESS_DENIED": "❌ Доступ запрещен",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
|
||||
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер",
|
||||
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Управление странами подписки</b>\n\n📋 <b>Текущие страны ({current_count}):</b>\n{current_list}\n\n💡 <b>Инструкция:</b>\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!",
|
||||
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
|
||||
"PAID_FEATURE_ONLY": "⚠ Эта функция доступна только для платных подписок",
|
||||
"PAID_FEATURE_ONLY_SHORT": "⚠ Только для платных подписок",
|
||||
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ Сервер недоступен для вашей промогруппы",
|
||||
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ Изменения не обнаружены",
|
||||
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
|
||||
"COUNTRY_CHANGES_ADDED_HEADER": "➕ <b>Добавлены страны:</b>\n",
|
||||
"COUNTRY_CHANGES_CHARGED": "💰 Списано: {amount} (за {months} мес)",
|
||||
"COUNTRY_CHANGES_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
|
||||
"COUNTRY_CHANGES_REMOVED_HEADER": "➖ <b>Отключены страны:</b>\n",
|
||||
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным",
|
||||
"COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 <b>Активных стран:</b> {count}",
|
||||
"ADMIN_MAIN_MENU": "🏠 Главное меню",
|
||||
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
@@ -38,6 +52,22 @@
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
|
||||
"ADMIN_USER_MANAGEMENT_PROFILE": "👤 <b>Управление пользователем</b>\n\n<b>Основная информация:</b>\n• Имя: {name}\n• ID: <code>{telegram_id}</code>\n• Username: {username}\n• Статус: {status}\n• Язык: {language}\n\n<b>Финансы:</b>\n• Баланс: {balance}\n• Транзакций: {transactions}\n\n<b>Активность:</b>\n• Регистрация: {registration}\n• Последняя активность: {last_activity}\n• Дней с регистрации: {registration_days}",
|
||||
"ADMIN_USER_USERNAME_NOT_SET": "не указан",
|
||||
"ADMIN_USER_STATUS_ACTIVE": "✅ Активен",
|
||||
"ADMIN_USER_STATUS_BLOCKED": "🚫 Заблокирован",
|
||||
"ADMIN_USER_STATUS_DELETED": "🗑️ Удален",
|
||||
"ADMIN_USER_STATUS_UNKNOWN": "❓ Неизвестно",
|
||||
"ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Неизвестно",
|
||||
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Триал",
|
||||
"ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Платная",
|
||||
"ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Активна",
|
||||
"ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Неактивна",
|
||||
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} ГБ",
|
||||
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "<b>Подписка:</b>\n• Тип: {type}\n• Статус: {status}\n• До: {end_date}\n• Трафик: {traffic}\n• Устройства: {devices}\n• Стран: {countries}",
|
||||
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Подписка:</b> Отсутствует",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Промогруппа:</b>\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%",
|
||||
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Промогруппа:</b> Не назначена",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Промогруппа:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
|
||||
@@ -72,6 +102,12 @@
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
|
||||
"AUTOPAY_STATUS_ENABLED": "включен",
|
||||
"AUTOPAY_STATUS_DISABLED": "выключен",
|
||||
"AUTOPAY_MENU_TEXT": "💳 <b>Автоплатеж</b>\n\n📊 <b>Статус:</b> {status}\n⏰ <b>Списание за:</b> {days} дн. до окончания\n\nВыберите действие:",
|
||||
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
|
||||
"AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Выберите за сколько дней до окончания списывать средства:",
|
||||
"AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
|
||||
"BALANCE_BUTTON": "💰 Баланс: {balance}",
|
||||
@@ -93,6 +129,7 @@
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CANCEL": "❌ Отмена",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
|
||||
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
|
||||
@@ -103,6 +140,7 @@
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
|
||||
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
|
||||
"CONFIRM": "✅ Подтвердить",
|
||||
@@ -128,6 +166,19 @@
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
|
||||
"DEVICE_CHANGE_ACTION_INCREASE": "увеличить до {count}",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_FREE": "Бесплатно",
|
||||
"DEVICE_CHANGE_ACTION_DECREASE": "уменьшить до {count}",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Подтверждение изменения</b>\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n",
|
||||
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
|
||||
"DEVICE_CHANGE_CHARGED": "💰 Списано: {amount}",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
@@ -144,6 +195,7 @@
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_PROMPT": "🌐 Выберите язык интерфейса:",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
|
||||
@@ -152,6 +204,26 @@
|
||||
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
|
||||
"MAIN_MENU_BUTTON": "🏠 Главное меню",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
|
||||
"DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден",
|
||||
"DEVICE_NONE_CONNECTED": "ℹ️ У вас нет подключенных устройств",
|
||||
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
|
||||
"DEVICE_MANAGEMENT_OVERVIEW": "🔄 <b>Управление устройствами</b>\n\n📊 Всего подключено: {total} устройств\n📄 Страница {page} из {pages}\n\n",
|
||||
"DEVICE_MANAGEMENT_CONNECTED_HEADER": "<b>Подключенные устройства:</b>\n",
|
||||
"DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
|
||||
"DEVICE_MANAGEMENT_ACTIONS": "\n💡 <b>Действия:</b>\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу",
|
||||
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
|
||||
"DEVICE_PAGE_LOAD_ERROR": "❌ Ошибка загрузки страницы",
|
||||
"DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос",
|
||||
"DEVICE_RESET_PARSE_ERROR": "❌ Ошибка обработки запроса",
|
||||
"DEVICE_RESET_SUCCESS": "✅ Устройство {device} успешно сброшено!",
|
||||
"DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены",
|
||||
"DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства",
|
||||
"DEVICE_RESET_NOT_FOUND": "❌ Устройство не найдено",
|
||||
"DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства",
|
||||
"DEVICE_LIST_FETCH_ERROR": "❌ Ошибка получения списка устройств",
|
||||
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>Все устройства успешно сброшены!</b>\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения",
|
||||
"DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ <b>Частичный сброс устройств</b>\n\n✅ Удалено: {success} устройств\n❌ Не удалось удалить: {failed} устройств\n\nПопробуйте еще раз или обратитесь в поддержку.",
|
||||
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Не удалось сбросить устройства</b>\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}",
|
||||
"MENU_ADMIN": "⚙️ Админ-панель",
|
||||
"MENU_BALANCE": "💰 Баланс",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
|
||||
@@ -233,6 +305,9 @@
|
||||
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Настройки подписки</b>\n\n📊 <b>Текущие параметры:</b>\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
|
||||
"SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ У вас нет активной подписки!",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
|
||||
@@ -296,6 +371,9 @@
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
|
||||
"ADD_TRAFFIC_PROMPT": "📈 <b>Добавить трафик к подписке</b>\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
"USER_NOT_FOUND": "❌ Пользователь не найден",
|
||||
@@ -332,7 +410,9 @@
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
@@ -414,6 +494,7 @@
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
@@ -424,6 +505,198 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
|
||||
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||
"ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена",
|
||||
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽",
|
||||
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы",
|
||||
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:",
|
||||
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»",
|
||||
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
|
||||
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
|
||||
"ADMIN_REPORTS": "📊 Отчеты",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
|
||||
"ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!",
|
||||
"ATTACHMENTS_SENT": "✅ Вложения отправлены.",
|
||||
"BACK_TO_MENU": "🏠 В главное меню",
|
||||
"BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты",
|
||||
"BACK_TO_SUPPORT": "⬅️ К поддержке",
|
||||
"BACK_TO_TICKETS": "⬅️ К тикетам",
|
||||
"BALANCE_TOPUP": "💳 Пополнить баланс",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
"BLOCK_FOREVER": "🚫 Заблокировать",
|
||||
"CAMPAIGN_EXISTING_USERL": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
|
||||
"CANCEL_REPLY": "❌ Отменить ответ",
|
||||
"CANCEL_TICKET_CREATION": "❌ Отменить создание тикета",
|
||||
"CLOSED_TICKETS": "🟢 Закрытые",
|
||||
"CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты",
|
||||
"CLOSED_TICKETS_TITLE": "🟢 Закрытые тикеты:",
|
||||
"CLOSE_NOTIFICATION": "❌ Закрыть уведомление",
|
||||
"CLOSE_TICKET": "🔒 Закрыть тикет",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
|
||||
"DELETE_MESSAGE": "🗑 Удалить",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
|
||||
"ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
|
||||
"LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
|
||||
"MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Mulen Pay</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay",
|
||||
"MULENPAY_TOPUP_PROMPT": "💳 <b>Оплата через Mulen Pay</b>\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.",
|
||||
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
|
||||
"MY_TICKETS_TITLE": "📋 Ваши тикеты:",
|
||||
"NOTIFICATION_CLOSED": "Уведомление закрыто.",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):",
|
||||
"NO_ATTACHMENTS": "Вложений нет.",
|
||||
"NO_CLOSED_TICKETS": "Закрытых тикетов пока нет.",
|
||||
"NO_TICKETS": "У вас пока нет тикетов.",
|
||||
"NO_TICKETS_ADMIN": "Нет тикетов для отображения.",
|
||||
"OPEN_TICKETS": "🔴 Открытые",
|
||||
"OPEN_TICKETS_HEADER": "🔴 Открытые тикеты",
|
||||
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
||||
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
|
||||
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
|
||||
"REPLY_TO_TICKET": "💬 Ответить",
|
||||
"REPORT_CLOSE": "❌ Закрыть",
|
||||
"REPORT_CLOSED": "✅ Отчет закрыт.",
|
||||
"REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.",
|
||||
"SENDING_ATTACHMENTS": "📎 Отправляю вложения...",
|
||||
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
|
||||
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
|
||||
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
|
||||
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
|
||||
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Подключение через Happ</b>",
|
||||
"SUPPORT_BUTTON": "🆘 Поддержка",
|
||||
"TICKET_ALREADY_OPEN": "У вас уже есть незакрытый тикет. Сначала закройте его.",
|
||||
"TICKET_ATTACHMENTS": "📎 Вложения",
|
||||
"TICKET_CLOSED": "✅ Тикет закрыт.",
|
||||
"TICKET_CLOSED_NO_REPLY": "❌ Тикет закрыт, ответить невозможно.",
|
||||
"TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.",
|
||||
"TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.",
|
||||
"TICKET_CREATE_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
|
||||
"TICKET_CREATION_CANCELLED": "Создание тикета отменено.",
|
||||
"TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
|
||||
"TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.",
|
||||
"TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:",
|
||||
"TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:",
|
||||
"TICKET_NOT_FOUND": "Тикет не найден.",
|
||||
"TICKET_PRIORITY_HIGH": "🟠 Высокий",
|
||||
"TICKET_PRIORITY_LOW": "🟢 Низкий",
|
||||
"TICKET_PRIORITY_NORMAL": "🟡 Обычный",
|
||||
"TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:",
|
||||
"TICKET_PRIORITY_URGENT": "🔴 Срочный",
|
||||
"TICKET_REPLY_CANCELLED": "Ответ отменен.",
|
||||
"TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.",
|
||||
"TICKET_REPLY_INPUT": "Введите ваш ответ:",
|
||||
"TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:",
|
||||
"TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!",
|
||||
"TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_STATUS_ANSWERED": "Отвечен",
|
||||
"TICKET_STATUS_CLOSED": "Закрыт",
|
||||
"TICKET_STATUS_OPEN": "Открыт",
|
||||
"TICKET_STATUS_PENDING": "В ожидании",
|
||||
"TICKET_TITLE_INPUT": "Введите заголовок тикета:",
|
||||
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
|
||||
"UNBLOCK": "✅ Разблокировать",
|
||||
"USER_BLOCKED_FOREVER": "Вы заблокированы для обращений в поддержку.",
|
||||
"USER_BLOCKED_UNTIL": "Вы заблокированы до {time}",
|
||||
"VIEW_CLOSED_TICKETS": "🟢 Закрытые тикеты",
|
||||
"VIEW_TICKET": "👁️ Посмотреть тикет",
|
||||
"ADMIN_USERS_SUBMENU_TITLE": "👥 **Управление пользователями и подписками**\n\n",
|
||||
"ADMIN_PROMO_SUBMENU_TITLE": "💰 **Промокоды и статистика**\n\n",
|
||||
"ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Коммуникации**\n\n",
|
||||
"ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Управление рассылками и текстами интерфейса:",
|
||||
"ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:",
|
||||
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
|
||||
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Управление тикетами и настройками поддержки:",
|
||||
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Доступ к тикетам.",
|
||||
"ADMIN_SUPPORT_MODERATION_TITLE": "🧑⚖️ <b>Модерация поддержки</b>",
|
||||
"ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Доступ к тикетам поддержки.",
|
||||
"ADMIN_SUPPORT_AUDIT_TITLE": "🧾 <b>Аудит модераторов</b>",
|
||||
"ADMIN_SUPPORT_AUDIT_EMPTY": "Пока пусто",
|
||||
"ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Модератор",
|
||||
"ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Админ",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Закрытие тикета",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Блокировка (время)",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Блокировка (навсегда)",
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
|
||||
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
|
||||
"ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Отключены",
|
||||
"ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "Пункт «Техподдержка» в меню",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Тикеты",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Контакт",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Оба",
|
||||
"ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Изменить описание",
|
||||
"ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Админ-уведомления",
|
||||
"ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "Пользовательские уведомления",
|
||||
"ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
|
||||
"ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ Время SLA: {minutes} мин",
|
||||
"ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑⚖️ Модераторы: {count}",
|
||||
"ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Назначить модератора",
|
||||
"ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Удалить модератора",
|
||||
"ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 <b>Настройки поддержки</b>",
|
||||
"ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:",
|
||||
"ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
|
||||
"ADMIN_SUPPORT_SLA_INVALID": "❌ Введите корректное число минут (1-1440)",
|
||||
"ADMIN_SUPPORT_SLA_SAVED": "✅ Значение SLA сохранено",
|
||||
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
|
||||
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
|
||||
"ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Введите корректный Telegram ID (число)",
|
||||
"ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Модератор {tid} удалён",
|
||||
"ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Не удалось удалить модератора",
|
||||
"ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ Пользователь {tid} назначен модератором",
|
||||
"ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Не удалось назначить модератора",
|
||||
"ADMIN_SUPPORT_MODERATORS_EMPTY": "Список пуст",
|
||||
"ADMIN_SUPPORT_MODERATORS_TITLE": "🧑⚖️ <b>Модераторы</b>",
|
||||
"ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Прислать текст",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 <b>Редактирование описания поддержки</b>",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Текущее описание:",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "<b>Контакт для режима «Контакт»</b>",
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже",
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено"
|
||||
}
|
||||
|
||||
+12
-5
@@ -12,6 +12,7 @@ from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.states import RegistrationStates
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -115,6 +116,7 @@ class AuthMiddleware(BaseMiddleware):
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_language.state,
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
@@ -125,7 +127,10 @@ class AuthMiddleware(BaseMiddleware):
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
and (
|
||||
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
|
||||
or event.data.startswith('language_select:')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -158,15 +163,17 @@ class AuthMiddleware(BaseMiddleware):
|
||||
logger.info(f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.first_name != user.first_name:
|
||||
safe_first = sanitize_telegram_name(user.first_name)
|
||||
safe_last = sanitize_telegram_name(user.last_name)
|
||||
if db_user.first_name != safe_first:
|
||||
old_first_name = db_user.first_name
|
||||
db_user.first_name = user.first_name
|
||||
db_user.first_name = safe_first
|
||||
logger.info(f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.last_name != user.last_name:
|
||||
if db_user.last_name != safe_last:
|
||||
old_last_name = db_user.last_name
|
||||
db_user.last_name = user.last_name
|
||||
db_user.last_name = safe_last
|
||||
logger.info(f"🔄 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
|
||||
@@ -7,8 +7,17 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, Subscription, Transaction, TransactionType
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
PromoCodeType,
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,20 +34,141 @@ class AdminNotificationService:
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
|
||||
try:
|
||||
referrer = await get_user_by_id(db, referred_by_id)
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
else:
|
||||
return f"ID {referrer.telegram_id}"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
|
||||
return f"ID {referred_by_id}"
|
||||
|
||||
async def _get_user_promo_group(self, db: AsyncSession, user: User) -> Optional[PromoGroup]:
|
||||
if getattr(user, "promo_group", None):
|
||||
return user.promo_group
|
||||
|
||||
if not user.promo_group_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
await db.refresh(user, attribute_names=["promo_group"])
|
||||
except Exception:
|
||||
# relationship might not be available — fallback to direct fetch
|
||||
pass
|
||||
|
||||
if getattr(user, "promo_group", None):
|
||||
return user.promo_group
|
||||
|
||||
try:
|
||||
return await get_promo_group_by_id(db, user.promo_group_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка загрузки промогруппы %s пользователя %s: %s",
|
||||
user.promo_group_id,
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
||||
discount_lines: List[str] = []
|
||||
|
||||
discount_map = {
|
||||
"servers": ("Серверы", promo_group.server_discount_percent),
|
||||
"traffic": ("Трафик", promo_group.traffic_discount_percent),
|
||||
"devices": ("Устройства", promo_group.device_discount_percent),
|
||||
}
|
||||
|
||||
for _, (title, percent) in discount_map.items():
|
||||
if percent and percent > 0:
|
||||
discount_lines.append(f"• {title}: -{percent}%")
|
||||
|
||||
period_discounts_raw = promo_group.period_discounts or {}
|
||||
period_items: List[tuple[int, int]] = []
|
||||
|
||||
if isinstance(period_discounts_raw, dict):
|
||||
for raw_days, raw_percent in period_discounts_raw.items():
|
||||
try:
|
||||
days = int(raw_days)
|
||||
percent = int(raw_percent)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if percent > 0:
|
||||
period_items.append((days, percent))
|
||||
|
||||
period_items.sort(key=lambda item: item[0])
|
||||
|
||||
if period_items:
|
||||
formatted_periods = ", ".join(
|
||||
f"{days} д. — -{percent}%" for days, percent in period_items
|
||||
)
|
||||
discount_lines.append(f"• Периоды: {formatted_periods}")
|
||||
|
||||
if promo_group.apply_discounts_to_addons:
|
||||
discount_lines.append("• Доп. услуги: ✅ скидка действует")
|
||||
else:
|
||||
discount_lines.append("• Доп. услуги: ❌ без скидки")
|
||||
|
||||
return discount_lines
|
||||
|
||||
def _format_promo_group_block(
|
||||
self,
|
||||
promo_group: Optional[PromoGroup],
|
||||
*,
|
||||
title: str = "Промогруппа",
|
||||
icon: str = "🏷️",
|
||||
) -> str:
|
||||
if not promo_group:
|
||||
return f"{icon} <b>{title}:</b> —"
|
||||
|
||||
lines = [f"{icon} <b>{title}:</b> {promo_group.name}"]
|
||||
|
||||
discount_lines = self._format_promo_group_discounts(promo_group)
|
||||
if discount_lines:
|
||||
lines.append("💸 <b>Скидки:</b>")
|
||||
lines.extend(discount_lines)
|
||||
else:
|
||||
lines.append("💸 <b>Скидки:</b> отсутствуют")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_promocode_type_display(self, promo_type: Optional[str]) -> str:
|
||||
mapping = {
|
||||
PromoCodeType.BALANCE.value: "💰 Бонус на баланс",
|
||||
PromoCodeType.SUBSCRIPTION_DAYS.value: "⏰ Доп. дни подписки",
|
||||
PromoCodeType.TRIAL_SUBSCRIPTION.value: "🎁 Триал подписка",
|
||||
}
|
||||
|
||||
if not promo_type:
|
||||
return "ℹ️ Не указан"
|
||||
|
||||
return mapping.get(promo_type, f"ℹ️ {promo_type}")
|
||||
|
||||
def _format_campaign_bonus(self, campaign: AdvertisingCampaign) -> List[str]:
|
||||
if campaign.is_balance_bonus:
|
||||
return [
|
||||
f"💰 Баланс: {settings.format_price(campaign.balance_bonus_kopeks or 0)}",
|
||||
]
|
||||
|
||||
if campaign.is_subscription_bonus:
|
||||
default_devices = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1)
|
||||
details = [
|
||||
f"📅 Дней подписки: {campaign.subscription_duration_days or 0}",
|
||||
f"📊 Трафик: {campaign.subscription_traffic_gb or 0} ГБ",
|
||||
f"📱 Устройства: {campaign.subscription_device_limit or default_devices}",
|
||||
]
|
||||
if campaign.subscription_squads:
|
||||
details.append(f"🌐 Сквады: {len(campaign.subscription_squads)} шт.")
|
||||
return details
|
||||
|
||||
return ["ℹ️ Бонусы не предусмотрены"]
|
||||
|
||||
async def send_trial_activation_notification(
|
||||
self,
|
||||
@@ -52,7 +182,9 @@ class AdminNotificationService:
|
||||
try:
|
||||
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -60,6 +192,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
|
||||
@@ -102,7 +236,9 @@ class AdminNotificationService:
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -110,6 +246,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -214,7 +352,7 @@ class AdminNotificationService:
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
deposit_count_result = await db.execute(
|
||||
select(func.count())
|
||||
@@ -230,7 +368,14 @@ class AdminNotificationService:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
subscription_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = subscription_result.scalar_one_or_none()
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
@@ -238,6 +383,8 @@ class AdminNotificationService:
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Детали пополнения:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -249,7 +396,7 @@ class AdminNotificationService:
|
||||
➕ Изменение: +{settings.format_price(balance_change)}
|
||||
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
@@ -266,21 +413,31 @@ class AdminNotificationService:
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
extended_days: int,
|
||||
old_end_date: datetime
|
||||
old_end_date: datetime,
|
||||
*,
|
||||
new_end_date: datetime | None = None,
|
||||
balance_after: int | None = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
current_end_date = new_end_date or subscription.end_date
|
||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
{promo_block}
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
@@ -289,23 +446,206 @@ class AdminNotificationService:
|
||||
📅 <b>Продление:</b>
|
||||
➕ Добавлено дней: {extended_days}
|
||||
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
|
||||
📱 <b>Текущие параметры:</b>
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def send_promocode_activation_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
promocode_data: Dict[str, Any],
|
||||
effect_description: str,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
type_display = self._get_promocode_type_display(promocode_data.get("type"))
|
||||
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
|
||||
|
||||
message_lines = [
|
||||
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🎟️ <b>Промокод:</b>",
|
||||
f"🔖 Код: <code>{promocode_data.get('code')}</code>",
|
||||
f"🧾 Тип: {type_display}",
|
||||
f"📊 Использования: {usage_info}",
|
||||
]
|
||||
|
||||
balance_bonus = promocode_data.get("balance_bonus_kopeks", 0)
|
||||
if balance_bonus:
|
||||
message_lines.append(
|
||||
f"💰 Бонус на баланс: {settings.format_price(balance_bonus)}"
|
||||
)
|
||||
|
||||
subscription_days = promocode_data.get("subscription_days", 0)
|
||||
if subscription_days:
|
||||
message_lines.append(f"📅 Доп. дни подписки: {subscription_days}")
|
||||
|
||||
valid_until = promocode_data.get("valid_until")
|
||||
if valid_until:
|
||||
message_lines.append(
|
||||
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
|
||||
if isinstance(valid_until, datetime)
|
||||
else f"⏳ Действует до: {valid_until}"
|
||||
)
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
"📝 <b>Эффект:</b>",
|
||||
effect_description.strip() or "✅ Промокод активирован",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об активации промокода: {e}")
|
||||
return False
|
||||
|
||||
async def send_campaign_link_visit_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
telegram_user: types.User,
|
||||
campaign: AdvertisingCampaign,
|
||||
user: Optional[User] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
user_status = "🆕 Новый пользователь" if not user else "👥 Уже зарегистрирован"
|
||||
promo_block = (
|
||||
self._format_promo_group_block(await self._get_user_promo_group(db, user))
|
||||
if user
|
||||
else self._format_promo_group_block(None)
|
||||
)
|
||||
|
||||
full_name = telegram_user.full_name or telegram_user.username or str(telegram_user.id)
|
||||
username = f"@{telegram_user.username}" if telegram_user.username else "отсутствует"
|
||||
|
||||
message_lines = [
|
||||
"📣 <b>ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ</b>",
|
||||
"",
|
||||
f"🧾 <b>Кампания:</b> {campaign.name}",
|
||||
f"🆔 ID кампании: {campaign.id}",
|
||||
f"🔗 Start-параметр: <code>{campaign.start_parameter}</code>",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{telegram_user.id}</code>",
|
||||
f"📱 <b>Username:</b> {username}",
|
||||
user_status,
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🎯 <b>Бонус кампании:</b>",
|
||||
]
|
||||
|
||||
bonus_lines = self._format_campaign_bonus(campaign)
|
||||
message_lines.extend(bonus_lines)
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о переходе по кампании: {e}")
|
||||
return False
|
||||
|
||||
async def send_user_promo_group_change_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
old_group: Optional[PromoGroup],
|
||||
new_group: PromoGroup,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
initiator: Optional[User] = None,
|
||||
automatic: bool = False,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
title = "🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ" if automatic else "👥 СМЕНА ПРОМОГРУППЫ"
|
||||
initiator_line = None
|
||||
if initiator:
|
||||
initiator_line = (
|
||||
f"👮 <b>Инициатор:</b> {initiator.full_name} (ID: {initiator.telegram_id})"
|
||||
)
|
||||
elif automatic:
|
||||
initiator_line = "🤖 Автоматическое назначение"
|
||||
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
|
||||
]
|
||||
|
||||
if old_group and old_group.id != new_group.id:
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
self._format_promo_group_block(
|
||||
old_group, title="Предыдущая промогруппа", icon="♻️"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if initiator_line:
|
||||
message_lines.extend(["", initiator_line])
|
||||
|
||||
if reason:
|
||||
message_lines.extend(["", f"📝 Причина: {reason}"])
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о смене промогруппы: {e}")
|
||||
return False
|
||||
|
||||
async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
|
||||
@@ -353,7 +693,7 @@ class AdminNotificationService:
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': '💳 Mulen Pay (карта)',
|
||||
'pal24': '💳 PayPalych (карта)',
|
||||
'pal24': '🏦 PayPalych (СБП)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
}
|
||||
@@ -361,22 +701,21 @@ class AdminNotificationService:
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
|
||||
return method_names.get(payment_method, f'💰 С баланса')
|
||||
return method_names.get(payment_method, '💰 С баланса')
|
||||
|
||||
def _format_traffic(self, traffic_gb: int) -> str:
|
||||
if traffic_gb == 0:
|
||||
return "∞ Безлимит"
|
||||
return f"{traffic_gb} ГБ"
|
||||
|
||||
def _get_subscription_status(self, user: User) -> str:
|
||||
if not user.subscription:
|
||||
def _get_subscription_status(self, subscription: Optional[Subscription]) -> str:
|
||||
if not subscription:
|
||||
return "❌ Нет подписки"
|
||||
|
||||
sub = user.subscription
|
||||
if sub.is_trial:
|
||||
return f"🎯 Триал (до {sub.end_date.strftime('%d.%m')})"
|
||||
elif sub.is_active:
|
||||
return f"✅ Активна (до {sub.end_date.strftime('%d.%m')})"
|
||||
|
||||
if subscription.is_trial:
|
||||
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
|
||||
elif subscription.is_active:
|
||||
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
|
||||
else:
|
||||
return "❌ Неактивна"
|
||||
|
||||
@@ -409,40 +748,32 @@ class AdminNotificationService:
|
||||
if details.get("auto_enabled", False):
|
||||
icon = "⚠️"
|
||||
title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "warning"
|
||||
else:
|
||||
icon = "🔧"
|
||||
title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "info"
|
||||
|
||||
elif event_type == "disable":
|
||||
icon = "✅"
|
||||
title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "success"
|
||||
|
||||
elif event_type == "api_status":
|
||||
if status == "online":
|
||||
icon = "🟢"
|
||||
title = "API REMNAWAVE ВОССТАНОВЛЕНО"
|
||||
alert_type = "success"
|
||||
else:
|
||||
icon = "🔴"
|
||||
title = "API REMNAWAVE НЕДОСТУПНО"
|
||||
alert_type = "error"
|
||||
|
||||
elif event_type == "monitoring":
|
||||
if status == "started":
|
||||
icon = "🔍"
|
||||
title = "МОНИТОРИНГ ЗАПУЩЕН"
|
||||
alert_type = "info"
|
||||
else:
|
||||
icon = "⏹️"
|
||||
title = "МОНИТОРИНГ ОСТАНОВЛЕН"
|
||||
alert_type = "info"
|
||||
else:
|
||||
icon = "ℹ️"
|
||||
title = "СИСТЕМА ТЕХРАБОТ"
|
||||
alert_type = "info"
|
||||
|
||||
message_parts = [f"{icon} <b>{title}</b>", ""]
|
||||
|
||||
@@ -632,103 +963,6 @@ class AdminNotificationService:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_remnawave_panel_status_notification(
|
||||
self,
|
||||
status: str,
|
||||
details: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
details = details or {}
|
||||
|
||||
status_config = {
|
||||
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
|
||||
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
|
||||
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
|
||||
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
|
||||
}
|
||||
|
||||
config = status_config.get(status, status_config["offline"])
|
||||
|
||||
message_parts = [
|
||||
f"{config['icon']} <b>{config['title']}</b>",
|
||||
""
|
||||
]
|
||||
|
||||
if details.get("api_url"):
|
||||
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
|
||||
|
||||
if details.get("response_time"):
|
||||
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
|
||||
|
||||
if details.get("last_check"):
|
||||
last_check = details["last_check"]
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
|
||||
|
||||
if details.get("users_online"):
|
||||
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("✅ Все системы работают нормально.")
|
||||
|
||||
elif status == "offline":
|
||||
if details.get("error"):
|
||||
error_msg = str(details["error"])[:150]
|
||||
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
|
||||
|
||||
if details.get("consecutive_failures"):
|
||||
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
|
||||
|
||||
elif status == "degraded":
|
||||
if details.get("issues"):
|
||||
issues = details["issues"]
|
||||
if isinstance(issues, list):
|
||||
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
|
||||
for issue in issues[:3]:
|
||||
message_parts.append(f" • {issue}")
|
||||
else:
|
||||
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель работает, но возможны задержки или сбои.")
|
||||
|
||||
elif status == "maintenance":
|
||||
if details.get("maintenance_reason"):
|
||||
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
|
||||
|
||||
if details.get("estimated_duration"):
|
||||
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
|
||||
|
||||
if details.get("manual_message"):
|
||||
message_parts.append(f"💬 <b>Сообщение:</b> {details['manual_message']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_update_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -744,50 +978,64 @@ class AdminNotificationService:
|
||||
|
||||
try:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
|
||||
}
|
||||
|
||||
|
||||
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
|
||||
|
||||
message = f"""{title}
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
🔧 <b>Изменение:</b>
|
||||
📋 Параметр: {param_name}"""
|
||||
message_lines = [
|
||||
f"{title}",
|
||||
"",
|
||||
f"👤 <b>Пользователь:</b> {user.full_name}",
|
||||
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
|
||||
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
|
||||
"",
|
||||
promo_block,
|
||||
"",
|
||||
"🔧 <b>Изменение:</b>",
|
||||
f"📋 Параметр: {param_name}",
|
||||
]
|
||||
|
||||
if update_type == "servers":
|
||||
old_servers_info = await self._format_servers_detailed(old_value)
|
||||
new_servers_info = await self._format_servers_detailed(new_value)
|
||||
|
||||
message += f"""
|
||||
📉 Было: {old_servers_info}
|
||||
📈 Стало: {new_servers_info}"""
|
||||
message_lines.extend(
|
||||
[
|
||||
f"📉 Было: {old_servers_info}",
|
||||
f"📈 Стало: {new_servers_info}",
|
||||
]
|
||||
)
|
||||
else:
|
||||
message += f"""
|
||||
📉 Было: {self._format_update_value(old_value, update_type)}
|
||||
📈 Стало: {self._format_update_value(new_value, update_type)}"""
|
||||
message_lines.extend(
|
||||
[
|
||||
f"📉 Было: {self._format_update_value(old_value, update_type)}",
|
||||
f"📈 Стало: {self._format_update_value(new_value, update_type)}",
|
||||
]
|
||||
)
|
||||
|
||||
if price_paid > 0:
|
||||
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
|
||||
message_lines.append(f"💰 Доплачено: {settings.format_price(price_paid)}")
|
||||
else:
|
||||
message += f"\n💸 Бесплатно"
|
||||
message_lines.append("💸 Бесплатно")
|
||||
|
||||
message += f"""
|
||||
message_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
|
||||
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
|
||||
f"🔗 <b>Рефер:</b> {referrer_info}",
|
||||
"",
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Рефер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
return await self._send_message("\n".join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
|
||||
|
||||
@@ -12,6 +12,7 @@ import aiofiles
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
@@ -23,7 +24,8 @@ from app.database.models import (
|
||||
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
|
||||
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
|
||||
MulenPayPayment, Pal24Payment
|
||||
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
|
||||
server_squad_promo_groups
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class BackupMetadata:
|
||||
timestamp: str
|
||||
version: str = "1.1"
|
||||
version: str = "1.2"
|
||||
database_type: str = "postgresql"
|
||||
backup_type: str = "full"
|
||||
tables_count: int = 0
|
||||
@@ -83,17 +85,23 @@ class BackupService:
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SentNotification,
|
||||
DiscountOffer,
|
||||
BroadcastHistory,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
SupportAuditLog,
|
||||
WebApiToken,
|
||||
]
|
||||
|
||||
if self._settings.include_logs:
|
||||
self.backup_models_ordered.append(MonitoringLog)
|
||||
|
||||
self.association_tables = {
|
||||
"server_squad_promo_groups": server_squad_promo_groups,
|
||||
}
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
return BackupSettings(
|
||||
auto_backup_enabled=os.getenv("BACKUP_AUTO_ENABLED", "true").lower() == "true",
|
||||
@@ -171,6 +179,7 @@ class BackupService:
|
||||
models_to_backup.append(MonitoringLog)
|
||||
|
||||
backup_data = {}
|
||||
association_data = {}
|
||||
total_records = 0
|
||||
|
||||
async for db in get_db():
|
||||
@@ -214,7 +223,11 @@ class BackupService:
|
||||
total_records += len(table_data)
|
||||
|
||||
logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
|
||||
|
||||
|
||||
association_data = await self._export_association_tables(db)
|
||||
for records in association_data.values():
|
||||
total_records += len(records)
|
||||
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте данных: {e}")
|
||||
@@ -226,7 +239,7 @@ class BackupService:
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
database_type="postgresql" if settings.is_postgresql() else "sqlite",
|
||||
backup_type="full",
|
||||
tables_count=len(models_to_backup),
|
||||
tables_count=len(models_to_backup) + len(association_data),
|
||||
total_records=total_records,
|
||||
compressed=compress,
|
||||
created_by=created_by,
|
||||
@@ -239,10 +252,17 @@ class BackupService:
|
||||
filename += ".gz"
|
||||
|
||||
backup_path = self.backup_dir / filename
|
||||
|
||||
|
||||
file_snapshots = await self._collect_file_snapshots()
|
||||
|
||||
backup_structure = {
|
||||
"metadata": asdict(metadata),
|
||||
"data": backup_data
|
||||
"data": backup_data,
|
||||
"associations": association_data,
|
||||
"files": file_snapshots,
|
||||
"config": {
|
||||
"backup_settings": asdict(self._settings)
|
||||
}
|
||||
}
|
||||
|
||||
if compress:
|
||||
@@ -271,7 +291,7 @@ class BackupService:
|
||||
size_mb = file_size / 1024 / 1024
|
||||
message = (f"✅ Бекап успешно создан!\n"
|
||||
f"📁 Файл: {filename}\n"
|
||||
f"📊 Таблиц: {len(models_to_backup)}\n"
|
||||
f"📊 Таблиц: {metadata.tables_count}\n"
|
||||
f"📈 Записей: {total_records:,}\n"
|
||||
f"💾 Размер: {size_mb:.2f} MB")
|
||||
|
||||
@@ -319,6 +339,8 @@ class BackupService:
|
||||
|
||||
metadata = backup_structure.get("metadata", {})
|
||||
backup_data = backup_structure.get("data", {})
|
||||
association_data = backup_structure.get("associations", {})
|
||||
file_snapshots = backup_structure.get("files", {})
|
||||
|
||||
if not backup_data:
|
||||
return False, "❌ Файл бекапа не содержит данных"
|
||||
@@ -377,6 +399,14 @@ class BackupService:
|
||||
|
||||
await self._update_user_referrals(db, backup_data)
|
||||
|
||||
assoc_tables, assoc_records = await self._restore_association_tables(
|
||||
db,
|
||||
association_data,
|
||||
clear_existing
|
||||
)
|
||||
restored_tables += assoc_tables
|
||||
restored_records += assoc_records
|
||||
|
||||
await db.commit()
|
||||
|
||||
break
|
||||
@@ -397,6 +427,11 @@ class BackupService:
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("restore_success", message)
|
||||
|
||||
if file_snapshots:
|
||||
restored_files = await self._restore_file_snapshots(file_snapshots)
|
||||
if restored_files:
|
||||
logger.info(f"📁 Восстановлено файлов конфигурации: {restored_files}")
|
||||
|
||||
return True, message
|
||||
|
||||
@@ -543,6 +578,97 @@ class BackupService:
|
||||
return col.name
|
||||
return None
|
||||
|
||||
async def _export_association_tables(self, db: AsyncSession) -> Dict[str, List[Dict[str, Any]]]:
|
||||
association_data: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
for table_name, table_obj in self.association_tables.items():
|
||||
try:
|
||||
logger.info(f"📊 Экспортируем таблицу связей: {table_name}")
|
||||
result = await db.execute(select(table_obj))
|
||||
rows = result.mappings().all()
|
||||
association_data[table_name] = [dict(row) for row in rows]
|
||||
logger.info(
|
||||
f"✅ Экспортировано {len(rows)} связей из {table_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка экспорта таблицы связей {table_name}: {e}")
|
||||
|
||||
return association_data
|
||||
|
||||
async def _restore_association_tables(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
association_data: Dict[str, List[Dict[str, Any]]],
|
||||
clear_existing: bool
|
||||
) -> Tuple[int, int]:
|
||||
if not association_data:
|
||||
return 0, 0
|
||||
|
||||
restored_tables = 0
|
||||
restored_records = 0
|
||||
|
||||
if "server_squad_promo_groups" in association_data:
|
||||
restored = await self._restore_server_squad_promo_groups(
|
||||
db,
|
||||
association_data["server_squad_promo_groups"],
|
||||
clear_existing
|
||||
)
|
||||
restored_tables += 1
|
||||
restored_records += restored
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
async def _restore_server_squad_promo_groups(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
records: List[Dict[str, Any]],
|
||||
clear_existing: bool
|
||||
) -> int:
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
if clear_existing:
|
||||
await db.execute(server_squad_promo_groups.delete())
|
||||
|
||||
restored = 0
|
||||
|
||||
for record in records:
|
||||
server_id = record.get("server_squad_id")
|
||||
promo_id = record.get("promo_group_id")
|
||||
|
||||
if server_id is None or promo_id is None:
|
||||
logger.warning(
|
||||
"Пропущена некорректная запись server_squad_promo_groups: %s",
|
||||
record
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
server_squad_promo_groups.insert().values(
|
||||
server_squad_id=server_id,
|
||||
promo_group_id=promo_id
|
||||
)
|
||||
)
|
||||
restored += 1
|
||||
except IntegrityError:
|
||||
logger.debug(
|
||||
"Запись server_squad_promo_groups (%s, %s) уже существует",
|
||||
server_id,
|
||||
promo_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s",
|
||||
server_id,
|
||||
promo_id,
|
||||
e
|
||||
)
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
return restored
|
||||
|
||||
async def _restore_table_records(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -590,17 +716,18 @@ class BackupService:
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession):
|
||||
tables_order = [
|
||||
"server_squad_promo_groups",
|
||||
"ticket_messages", "tickets", "support_audit_logs",
|
||||
"advertising_campaign_registrations", "advertising_campaigns",
|
||||
"subscription_servers", "sent_notifications",
|
||||
"user_messages", "broadcast_history", "subscription_conversions",
|
||||
"discount_offers", "user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses",
|
||||
"yookassa_payments", "cryptobot_payments",
|
||||
"mulenpay_payments", "pal24_payments",
|
||||
"transactions", "welcome_texts", "subscriptions",
|
||||
"promocodes", "users", "promo_groups",
|
||||
"server_squads", "squads", "service_rules",
|
||||
"system_settings", "monitoring_logs"
|
||||
"system_settings", "web_api_tokens", "monitoring_logs"
|
||||
]
|
||||
|
||||
for table_name in tables_order:
|
||||
@@ -610,6 +737,57 @@ class BackupService:
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {e}")
|
||||
|
||||
async def _collect_file_snapshots(self) -> Dict[str, Dict[str, Any]]:
|
||||
snapshots: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
app_config_path = settings.get_app_config_path()
|
||||
if app_config_path:
|
||||
path_obj = Path(app_config_path)
|
||||
if path_obj.exists() and path_obj.is_file():
|
||||
try:
|
||||
async with aiofiles.open(path_obj, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
snapshots["app_config"] = {
|
||||
"path": str(path_obj),
|
||||
"content": content,
|
||||
"modified_at": datetime.fromtimestamp(
|
||||
path_obj.stat().st_mtime
|
||||
).isoformat()
|
||||
}
|
||||
logger.info(
|
||||
"📁 Добавлен в бекап файл конфигурации: %s",
|
||||
path_obj
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Ошибка чтения файла конфигурации %s: %s",
|
||||
path_obj,
|
||||
e
|
||||
)
|
||||
|
||||
return snapshots
|
||||
|
||||
async def _restore_file_snapshots(self, file_snapshots: Dict[str, Dict[str, Any]]) -> int:
|
||||
restored_files = 0
|
||||
|
||||
if not file_snapshots:
|
||||
return restored_files
|
||||
|
||||
app_config_snapshot = file_snapshots.get("app_config")
|
||||
if app_config_snapshot:
|
||||
target_path = Path(settings.get_app_config_path())
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
async with aiofiles.open(target_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(app_config_snapshot.get("content", ""))
|
||||
restored_files += 1
|
||||
logger.info("📁 Файл app-config восстановлен по пути %s", target_path)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка восстановления файла %s: %s", target_path, e)
|
||||
|
||||
return restored_files
|
||||
|
||||
async def get_backup_list(self) -> List[Dict[str, Any]]:
|
||||
backups = []
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import BroadcastHistory
|
||||
from app.handlers.admin.messages import (
|
||||
create_broadcast_keyboard,
|
||||
get_custom_users,
|
||||
get_target_users,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_MEDIA_TYPES = {"photo", "video", "document"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BroadcastMediaConfig:
|
||||
type: str
|
||||
file_id: str
|
||||
caption: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BroadcastConfig:
|
||||
target: str
|
||||
message_text: str
|
||||
selected_buttons: list[str]
|
||||
media: Optional[BroadcastMediaConfig] = None
|
||||
initiator_name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _BroadcastTask:
|
||||
task: asyncio.Task
|
||||
cancel_event: asyncio.Event
|
||||
|
||||
|
||||
class BroadcastService:
|
||||
"""Handles broadcast execution triggered from the admin web API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._bot: Optional[Bot] = None
|
||||
self._tasks: dict[int, _BroadcastTask] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self._bot = bot
|
||||
|
||||
def is_running(self, broadcast_id: int) -> bool:
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
return bool(task_entry and not task_entry.task.done())
|
||||
|
||||
async def start_broadcast(self, broadcast_id: int, config: BroadcastConfig) -> None:
|
||||
if self._bot is None:
|
||||
logger.error("Невозможно запустить рассылку %s: бот не инициализирован", broadcast_id)
|
||||
await self._mark_failed(broadcast_id)
|
||||
return
|
||||
|
||||
cancel_event = asyncio.Event()
|
||||
|
||||
async with self._lock:
|
||||
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
|
||||
logger.warning("Рассылка %s уже запущена", broadcast_id)
|
||||
return
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._run_broadcast(broadcast_id, config, cancel_event),
|
||||
name=f"broadcast-{broadcast_id}",
|
||||
)
|
||||
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
|
||||
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
|
||||
|
||||
async def request_stop(self, broadcast_id: int) -> bool:
|
||||
async with self._lock:
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
if not task_entry:
|
||||
return False
|
||||
|
||||
task_entry.cancel_event.set()
|
||||
return True
|
||||
|
||||
async def _run_broadcast(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
config: BroadcastConfig,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> None:
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error("Запись рассылки %s не найдена в БД", broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.status = "in_progress"
|
||||
broadcast.sent_count = 0
|
||||
broadcast.failed_count = 0
|
||||
await session.commit()
|
||||
|
||||
recipients = await self._fetch_recipients(config.target)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error("Запись рассылки %s удалена до запуска", broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.total_count = len(recipients)
|
||||
await session.commit()
|
||||
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
if not recipients:
|
||||
logger.info("Рассылка %s: получатели не найдены", broadcast_id)
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
return
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
|
||||
for index, user in enumerate(recipients, start=1):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id is None:
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
sent_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
if index % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("Критическая ошибка при выполнении рассылки %s: %s", broadcast_id, exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count)
|
||||
|
||||
async def _fetch_recipients(self, target: str):
|
||||
async with AsyncSessionLocal() as session:
|
||||
if target.startswith("custom_"):
|
||||
criteria = target[len("custom_"):]
|
||||
return await get_custom_users(session, criteria)
|
||||
return await get_target_users(session, target)
|
||||
|
||||
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
|
||||
if selected_buttons is None:
|
||||
selected_buttons = []
|
||||
return create_broadcast_keyboard(selected_buttons)
|
||||
|
||||
async def _deliver_message(
|
||||
self,
|
||||
telegram_id: int,
|
||||
config: BroadcastConfig,
|
||||
keyboard: Optional[InlineKeyboardMarkup],
|
||||
) -> None:
|
||||
if not self._bot:
|
||||
raise RuntimeError("Телеграм-бот не инициализирован")
|
||||
|
||||
if config.media and config.media.type in VALID_MEDIA_TYPES:
|
||||
caption = config.media.caption or config.message_text
|
||||
if config.media.type == "photo":
|
||||
await self._bot.send_photo(
|
||||
chat_id=telegram_id,
|
||||
photo=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == "video":
|
||||
await self._bot.send_video(
|
||||
chat_id=telegram_id,
|
||||
video=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == "document":
|
||||
await self._bot.send_document(
|
||||
chat_id=telegram_id,
|
||||
document=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
|
||||
await self._bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=config.message_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
async def _mark_finished(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
*,
|
||||
cancelled: bool,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count
|
||||
broadcast.status = "cancelled" if cancelled else (
|
||||
"completed" if failed_count == 0 else "partial"
|
||||
)
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _mark_cancelled(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
) -> None:
|
||||
await self._mark_finished(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled=True,
|
||||
)
|
||||
|
||||
async def _mark_failed(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int = 0,
|
||||
failed_count: int = 0,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count or broadcast.failed_count
|
||||
broadcast.status = "failed"
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
|
||||
broadcast_service = BroadcastService()
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.database.crud.discount_offer import (
|
||||
upsert_discount_offer,
|
||||
)
|
||||
from app.database.crud.notification import (
|
||||
clear_notification_by_type,
|
||||
notification_sent,
|
||||
record_notification,
|
||||
)
|
||||
@@ -472,6 +473,10 @@ class MonitoringService:
|
||||
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
notifications_allowed = (
|
||||
NotificationSettingsService.are_notifications_globally_enabled()
|
||||
and NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
@@ -550,6 +555,22 @@ class MonitoringService:
|
||||
user.remnawave_uuid,
|
||||
api_error,
|
||||
)
|
||||
|
||||
if notifications_allowed:
|
||||
if not await notification_sent(
|
||||
db,
|
||||
user.id,
|
||||
subscription.id,
|
||||
"trial_channel_unsubscribed",
|
||||
):
|
||||
sent = await self._send_trial_channel_unsubscribed_notification(user)
|
||||
if sent:
|
||||
await record_notification(
|
||||
db,
|
||||
user.id,
|
||||
subscription.id,
|
||||
"trial_channel_unsubscribed",
|
||||
)
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
@@ -575,6 +596,12 @@ class MonitoringService:
|
||||
api_error,
|
||||
)
|
||||
|
||||
await clear_notification_by_type(
|
||||
db,
|
||||
subscription.id,
|
||||
"trial_channel_unsubscribed",
|
||||
)
|
||||
|
||||
if disabled_count or restored_count:
|
||||
await self._log_monitoring_event(
|
||||
db,
|
||||
@@ -795,7 +822,12 @@ class MonitoringService:
|
||||
|
||||
if success:
|
||||
await extend_subscription(db, subscription, 30)
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
await self.subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="автопродление подписки",
|
||||
)
|
||||
|
||||
if self.bot:
|
||||
await self._send_autopay_success_notification(user, renewal_cost, 30)
|
||||
@@ -1035,6 +1067,69 @@ class MonitoringService:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_trial_channel_unsubscribed_notification(self, user: User) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
template = texts.get(
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED",
|
||||
(
|
||||
"🚫 <b>Доступ приостановлен</b>\n\n"
|
||||
"Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
|
||||
"Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
|
||||
),
|
||||
)
|
||||
|
||||
check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
|
||||
message = template.format(check_button=check_button)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
buttons = []
|
||||
if settings.CHANNEL_LINK:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
||||
url=settings.CHANNEL_LINK,
|
||||
)
|
||||
]
|
||||
)
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=check_button,
|
||||
callback_data="sub_channel_check",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, "уведомление об отписке от канала"):
|
||||
return True
|
||||
logger.error(
|
||||
"Ошибка Telegram API при отправке уведомления об отписке от канала пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления об отписке от канала пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
@@ -1325,10 +1420,17 @@ class MonitoringService:
|
||||
if len(title) > 60:
|
||||
title = title[:57] + '...'
|
||||
|
||||
# Детали пользователя: имя, Telegram ID и username
|
||||
full_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
telegram_id_display = ticket.user.telegram_id if ticket.user else "—"
|
||||
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
|
||||
|
||||
text = (
|
||||
f"⏰ <b>Ожидание ответа на тикет превышено</b>\n\n"
|
||||
f"🆔 <b>ID:</b> <code>{ticket.id}</code>\n"
|
||||
f"👤 <b>User ID:</b> <code>{ticket.user_id}</code>\n"
|
||||
f"👤 <b>Пользователь:</b> {full_name}\n"
|
||||
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
|
||||
f"📱 <b>Username:</b> @{username_display}\n"
|
||||
f"📝 <b>Заголовок:</b> {title or '—'}\n"
|
||||
f"⏱️ <b>Ожидает ответа:</b> {waited_minutes} мин\n"
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
@@ -21,6 +20,7 @@ class NotificationSettingsService:
|
||||
_DEFAULTS: Dict[str, Dict[str, Any]] = {
|
||||
"trial_inactive_1h": {"enabled": True},
|
||||
"trial_inactive_24h": {"enabled": True},
|
||||
"trial_channel_unsubscribed": {"enabled": True},
|
||||
"expired_1d": {"enabled": True},
|
||||
"expired_second_wave": {
|
||||
"enabled": True,
|
||||
@@ -139,6 +139,14 @@ class NotificationSettingsService:
|
||||
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("trial_inactive_24h", enabled)
|
||||
|
||||
@classmethod
|
||||
def is_trial_channel_unsubscribed_enabled(cls) -> bool:
|
||||
return cls.is_enabled("trial_channel_unsubscribed")
|
||||
|
||||
@classmethod
|
||||
def set_trial_channel_unsubscribed_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled("trial_channel_unsubscribed", enabled)
|
||||
|
||||
# Expired subscription notifications
|
||||
@classmethod
|
||||
def is_expired_1d_enabled(cls) -> bool:
|
||||
|
||||
@@ -37,6 +37,7 @@ class Pal24Service:
|
||||
ttl_seconds: Optional[int] = None,
|
||||
custom_payload: Optional[Dict[str, Any]] = None,
|
||||
payer_email: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not self.is_configured:
|
||||
raise Pal24APIError("Pal24 service is not configured")
|
||||
@@ -49,6 +50,8 @@ class Pal24Service:
|
||||
|
||||
if payer_email:
|
||||
extra_payload["payer_email"] = payer_email
|
||||
if payment_method:
|
||||
extra_payload["payment_method"] = payment_method
|
||||
|
||||
filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})}
|
||||
|
||||
|
||||
@@ -857,6 +857,7 @@ class PaymentService:
|
||||
ttl_seconds=ttl_seconds,
|
||||
custom_payload=custom_payload,
|
||||
payer_email=payer_email,
|
||||
payment_method="SBP",
|
||||
)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||||
@@ -871,8 +872,55 @@ class PaymentService:
|
||||
logger.error("Pal24 не вернул bill_id: %s", response)
|
||||
return None
|
||||
|
||||
link_url = response.get("link_url")
|
||||
link_page_url = response.get("link_page_url")
|
||||
def _pick_url(*keys: str) -> Optional[str]:
|
||||
for key in keys:
|
||||
value = response.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
transfer_url = _pick_url(
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_url = _pick_url(
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
link_page_url = _pick_url(
|
||||
"link_page_url",
|
||||
"linkPageUrl",
|
||||
"page_url",
|
||||
"pageUrl",
|
||||
)
|
||||
|
||||
primary_link = transfer_url or link_page_url or card_url
|
||||
secondary_link = link_page_url or card_url or transfer_url
|
||||
|
||||
metadata_links = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"sbp": transfer_url,
|
||||
"card": card_url,
|
||||
"page": link_page_url,
|
||||
}.items()
|
||||
if value
|
||||
}
|
||||
|
||||
payment = await create_pal24_payment(
|
||||
db,
|
||||
@@ -884,22 +932,26 @@ class PaymentService:
|
||||
status=response.get("status", "NEW"),
|
||||
type_=response.get("type", "normal"),
|
||||
currency=response.get("currency", "RUB"),
|
||||
link_url=link_url,
|
||||
link_page_url=link_page_url,
|
||||
link_url=primary_link,
|
||||
link_page_url=secondary_link,
|
||||
ttl=ttl_seconds,
|
||||
metadata={
|
||||
"raw_response": response,
|
||||
"language": language,
|
||||
**({"links": metadata_links} if metadata_links else {}),
|
||||
},
|
||||
)
|
||||
|
||||
payment_info = {
|
||||
"bill_id": bill_id,
|
||||
"order_id": order_id,
|
||||
"link_url": link_url or link_page_url,
|
||||
"link_page_url": link_page_url,
|
||||
"link_url": primary_link,
|
||||
"link_page_url": secondary_link,
|
||||
"local_payment_id": payment.id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"sbp_url": transfer_url or primary_link,
|
||||
"card_url": card_url,
|
||||
"transfer_url": transfer_url,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -2,15 +2,64 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _notify_admins_about_auto_assignment(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
old_group: Optional[PromoGroup],
|
||||
new_group: PromoGroup,
|
||||
total_spent_kopeks: int,
|
||||
):
|
||||
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
|
||||
return
|
||||
|
||||
bot_token = getattr(settings, "BOT_TOKEN", None)
|
||||
if not bot_token:
|
||||
logger.debug("BOT_TOKEN не настроен — пропускаем уведомление о промогруппе")
|
||||
return
|
||||
|
||||
bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode="HTML"))
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
reason = (
|
||||
f"Автоназначение за траты {settings.format_price(total_spent_kopeks)}"
|
||||
if hasattr(settings, "format_price")
|
||||
else f"Автоназначение за траты {total_spent_kopeks / 100:.2f}₽"
|
||||
)
|
||||
await notification_service.send_user_promo_group_change_notification(
|
||||
db,
|
||||
user,
|
||||
old_group,
|
||||
new_group,
|
||||
reason=reason,
|
||||
initiator=None,
|
||||
automatic=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о автоназначении промогруппы пользователю %s: %s",
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
await bot.session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _get_best_group_for_spending(
|
||||
db: AsyncSession,
|
||||
total_spent_kopeks: int,
|
||||
@@ -48,6 +97,14 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
|
||||
return None
|
||||
|
||||
old_group = None
|
||||
if user.promo_group_id:
|
||||
try:
|
||||
await db.refresh(user, attribute_names=["promo_group"])
|
||||
except Exception:
|
||||
pass
|
||||
old_group = getattr(user, "promo_group", None)
|
||||
|
||||
total_spent = await get_user_total_spent_kopeks(db, user_id)
|
||||
if total_spent <= 0:
|
||||
return None
|
||||
@@ -112,6 +169,15 @@ async def maybe_assign_promo_group_by_total_spent(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if target_group.id != previous_group_id:
|
||||
await _notify_admins_about_auto_assignment(
|
||||
db,
|
||||
user,
|
||||
old_group,
|
||||
target_group,
|
||||
total_spent,
|
||||
)
|
||||
|
||||
return target_group
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
|
||||
@@ -49,23 +49,34 @@ class PromoCodeService:
|
||||
return {"success": False, "error": "already_used_by_user"}
|
||||
|
||||
result_description = await self._apply_promocode_effects(db, user, promocode)
|
||||
|
||||
|
||||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
await mark_user_as_had_paid_subscription(db, user)
|
||||
|
||||
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
|
||||
|
||||
|
||||
await create_promocode_use(db, promocode.id, user_id)
|
||||
|
||||
|
||||
promocode.current_uses += 1
|
||||
await db.commit()
|
||||
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
|
||||
|
||||
|
||||
promocode_data = {
|
||||
"code": promocode.code,
|
||||
"type": promocode.type,
|
||||
"balance_bonus_kopeks": promocode.balance_bonus_kopeks,
|
||||
"subscription_days": promocode.subscription_days,
|
||||
"max_uses": promocode.max_uses,
|
||||
"current_uses": promocode.current_uses,
|
||||
"valid_until": promocode.valid_until,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"description": result_description
|
||||
"description": result_description,
|
||||
"promocode": promocode_data,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -987,15 +987,6 @@ class RemnaWaveService:
|
||||
logger.error(f"Error removing users from squad: {e}")
|
||||
return False
|
||||
|
||||
async def delete_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
response = await api.delete_internal_squad(squad_uuid)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting squad: {e}")
|
||||
return False
|
||||
|
||||
async def get_all_inbounds(self) -> List[Dict]:
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
@@ -1030,15 +1021,6 @@ class RemnaWaveService:
|
||||
logger.error(f"Error renaming squad: {e}")
|
||||
return False
|
||||
|
||||
async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
squad = await api.create_internal_squad(name, inbound_uuids)
|
||||
return squad is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating squad: {e}")
|
||||
return False
|
||||
|
||||
async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
|
||||
@@ -17,6 +17,8 @@ from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
@@ -203,6 +205,10 @@ class ReportingService:
|
||||
f"• Активных сейчас: {totals['active_paid']}",
|
||||
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
|
||||
"",
|
||||
"🎟️ <b>Тикеты поддержки</b>",
|
||||
f"• Активных сейчас: {totals['open_tickets']}",
|
||||
f"• Новых за период: {period_stats['new_tickets']}",
|
||||
"",
|
||||
"💰 <b>Платежи</b>",
|
||||
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
|
||||
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
|
||||
@@ -243,9 +249,22 @@ class ReportingService:
|
||||
|
||||
async def _collect_current_totals(self, session) -> dict:
|
||||
stats = await get_subscriptions_statistics(session)
|
||||
open_tickets_result = await session.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.status.in_(
|
||||
[
|
||||
TicketStatus.OPEN.value,
|
||||
TicketStatus.ANSWERED.value,
|
||||
TicketStatus.PENDING.value,
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
open_tickets = int(open_tickets_result.scalar() or 0)
|
||||
return {
|
||||
"active_trials": stats.get("trial_subscriptions", 0) or 0,
|
||||
"active_paid": stats.get("paid_subscriptions", 0) or 0,
|
||||
"open_tickets": open_tickets,
|
||||
}
|
||||
|
||||
async def _collect_period_stats(
|
||||
@@ -315,6 +334,13 @@ class ReportingService:
|
||||
|
||||
total_payments_count = subscription_payments_count + deposits_count
|
||||
total_payments_amount = subscription_payments_amount + deposits_amount
|
||||
new_tickets_result = await session.execute(
|
||||
select(func.count(Ticket.id)).where(
|
||||
Ticket.created_at >= start_utc,
|
||||
Ticket.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
new_tickets = int(new_tickets_result.scalar() or 0)
|
||||
|
||||
return {
|
||||
"new_trials": new_trials,
|
||||
@@ -325,6 +351,7 @@ class ReportingService:
|
||||
"deposits_amount": deposits_amount,
|
||||
"total_payments_count": total_payments_count,
|
||||
"total_payments_amount": total_payments_amount,
|
||||
"new_tickets": new_tickets,
|
||||
}
|
||||
|
||||
def _format_period_label(self, start: datetime, end: datetime) -> str:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -6,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
@@ -75,21 +76,62 @@ def get_traffic_reset_strategy():
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
|
||||
|
||||
def __init__(self):
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
base_url = (auth_params.get("base_url") or "").strip()
|
||||
api_key = (auth_params.get("api_key") or "").strip()
|
||||
|
||||
self._config_error: Optional[str] = None
|
||||
|
||||
if not base_url:
|
||||
self._config_error = "REMNAWAVE_API_URL не настроен"
|
||||
elif not api_key:
|
||||
self._config_error = "REMNAWAVE_API_KEY не настроен"
|
||||
|
||||
if self._config_error:
|
||||
logger.warning(
|
||||
"RemnaWave API недоступен: %s. Подписочный сервис будет работать в оффлайн-режиме.",
|
||||
self._config_error
|
||||
)
|
||||
self.api = None
|
||||
else:
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=auth_params.get("secret_key"),
|
||||
username=auth_params.get("username"),
|
||||
password=auth_params.get("password")
|
||||
)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self._config_error is None
|
||||
|
||||
@property
|
||||
def configuration_error(self) -> Optional[str]:
|
||||
return self._config_error
|
||||
|
||||
def _ensure_configured(self) -> None:
|
||||
if not self.api or not self.is_configured:
|
||||
raise RemnaWaveAPIError(
|
||||
self._config_error or "RemnaWave API не настроен"
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_api_client(self):
|
||||
self._ensure_configured()
|
||||
assert self.api is not None
|
||||
async with self.api as api:
|
||||
yield api
|
||||
|
||||
async def create_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
*,
|
||||
reset_traffic: bool = False,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
@@ -103,7 +145,7 @@ class SubscriptionService:
|
||||
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
|
||||
@@ -130,6 +172,14 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
updated_user.uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
|
||||
username = f"user_{user.telegram_id}"
|
||||
@@ -148,7 +198,15 @@ class SubscriptionService:
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
updated_user.uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
@@ -172,7 +230,10 @@ class SubscriptionService:
|
||||
async def update_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
subscription: Subscription,
|
||||
*,
|
||||
reset_traffic: bool = False,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
@@ -194,7 +255,7 @@ class SubscriptionService:
|
||||
is_actually_active = False
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
updated_user = await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
@@ -210,6 +271,14 @@ class SubscriptionService:
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if reset_traffic:
|
||||
await self._reset_user_traffic(
|
||||
api,
|
||||
user.remnawave_uuid,
|
||||
user.telegram_id,
|
||||
reset_reason,
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
await db.commit()
|
||||
@@ -219,18 +288,39 @@ class SubscriptionService:
|
||||
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
|
||||
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
|
||||
return updated_user
|
||||
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
|
||||
async def _reset_user_traffic(
|
||||
self,
|
||||
api: RemnaWaveAPI,
|
||||
user_uuid: str,
|
||||
telegram_id: int,
|
||||
reset_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
if not user_uuid:
|
||||
return
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
await api.reset_user_traffic(user_uuid)
|
||||
reason_text = f" ({reset_reason})" if reset_reason else ""
|
||||
logger.info(
|
||||
f"🔄 Сброшен трафик RemnaWave для пользователя {telegram_id}{reason_text}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"⚠️ Не удалось сбросить трафик RemnaWave для пользователя {telegram_id}: {exc}"
|
||||
)
|
||||
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
await api.disable_user(user_uuid)
|
||||
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
|
||||
return True
|
||||
@@ -250,7 +340,7 @@ class SubscriptionService:
|
||||
if not user or not user.remnawave_uuid:
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
updated_user = await api.revoke_user_subscription(user.remnawave_uuid)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
@@ -268,7 +358,7 @@ class SubscriptionService:
|
||||
async def get_subscription_info(self, short_uuid: str) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
info = await api.get_subscription_info(short_uuid)
|
||||
return info
|
||||
|
||||
@@ -287,7 +377,7 @@ class SubscriptionService:
|
||||
if not user or not user.remnawave_uuid:
|
||||
return False
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if not remnawave_user:
|
||||
return False
|
||||
@@ -534,7 +624,7 @@ class SubscriptionService:
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
if not remnawave_user:
|
||||
|
||||
@@ -4,10 +4,12 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
|
||||
|
||||
from app.database.universal_migration import ensure_default_web_api_token
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import Settings, settings
|
||||
from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices
|
||||
from app.database.crud.system_setting import (
|
||||
delete_system_setting,
|
||||
upsert_system_setting,
|
||||
@@ -58,90 +60,158 @@ class BotConfigurationService:
|
||||
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
|
||||
|
||||
CATEGORY_TITLES: Dict[str, str] = {
|
||||
"SUPPORT": "Поддержка",
|
||||
"ADMIN_NOTIFICATIONS": "Уведомления администраторов",
|
||||
"ADMIN_REPORTS": "Автоотчеты",
|
||||
"CHANNEL": "Обязательная подписка на канал",
|
||||
"DATABASE": "База данных",
|
||||
"POSTGRES": "PostgreSQL",
|
||||
"SQLITE": "SQLite",
|
||||
"REDIS": "Redis",
|
||||
"REMNAWAVE": "Remnawave API",
|
||||
"TRIAL": "Триал подписка",
|
||||
"PAID_SUBSCRIPTION": "Платная подписка",
|
||||
"SUBSCRIPTIONS_GLOBAL": "Глобальные параметры подписок",
|
||||
"TRAFFIC": "Настройки трафика",
|
||||
"PERIODS": "Периоды подписки",
|
||||
"SUBSCRIPTION_PRICES": "Цены подписки",
|
||||
"TRAFFIC_PACKAGES": "Пакеты трафика",
|
||||
"DISCOUNTS": "Скидки промогрупп",
|
||||
"REFERRAL": "Реферальная система",
|
||||
"AUTOPAY": "Автопродление",
|
||||
"TELEGRAM": "Telegram Stars",
|
||||
"TRIBUTE": "Tribute",
|
||||
"YOOKASSA": "YooKassa",
|
||||
"CRYPTOBOT": "CryptoBot",
|
||||
"MULENPAY": "MulenPay",
|
||||
"PAL24": "PayPalych / Pal24",
|
||||
"PAYMENT": "Описания платежей",
|
||||
"INTERFACE_BRANDING": "Брендинг и логотип",
|
||||
"INTERFACE_SUBSCRIPTION": "Блок подписки",
|
||||
"CONNECT_BUTTON": "Кнопка «Подключиться»",
|
||||
"HAPP": "Happ CryptoLink",
|
||||
"SKIP": "Пропуски onboarding",
|
||||
"MONITORING": "Мониторинг",
|
||||
"NOTIFICATIONS": "Уведомления",
|
||||
"SERVER": "Статус серверов",
|
||||
"MAINTENANCE": "Технические работы",
|
||||
"LOCALIZATION": "Локализация",
|
||||
"ADDITIONAL": "Дополнительные настройки",
|
||||
"BACKUP": "Бекапы",
|
||||
"VERSION": "Проверка обновлений",
|
||||
"LOG": "Логирование",
|
||||
"WEBHOOK": "Вебхуки",
|
||||
"DEBUG": "Режим разработки",
|
||||
"CORE": "🤖 Основные настройки",
|
||||
"SUPPORT": "💬 Поддержка и тикеты",
|
||||
"LOCALIZATION": "🌍 Языки интерфейса",
|
||||
"CHANNEL": "📣 Обязательная подписка",
|
||||
"PAYMENT": "💳 Общие платежные настройки",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 MulenPay",
|
||||
"PAL24": "🏦 PAL24 / PayPalych",
|
||||
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
|
||||
"PERIODS": "📆 Периоды подписок",
|
||||
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
|
||||
"TRAFFIC": "📊 Трафик",
|
||||
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
|
||||
"TRIAL": "🎁 Пробный период",
|
||||
"REFERRAL": "👥 Реферальная программа",
|
||||
"AUTOPAY": "🔄 Автопродление",
|
||||
"NOTIFICATIONS": "🔔 Уведомления пользователям",
|
||||
"ADMIN_NOTIFICATIONS": "📣 Оповещения администраторам",
|
||||
"ADMIN_REPORTS": "🗂 Автоматические отчеты",
|
||||
"INTERFACE": "🎨 Интерфейс и брендинг",
|
||||
"INTERFACE_BRANDING": "🖼️ Брендинг",
|
||||
"INTERFACE_SUBSCRIPTION": "🔗 Ссылка на подписку",
|
||||
"CONNECT_BUTTON": "🚀 Кнопка подключения",
|
||||
"MINIAPP": "📱 Mini App",
|
||||
"HAPP": "🅷 Happ",
|
||||
"SKIP": "⚡ Быстрый старт",
|
||||
"ADDITIONAL": "📱 Дополнительные приложения",
|
||||
"DATABASE": "💾 База данных",
|
||||
"POSTGRES": "🐘 PostgreSQL",
|
||||
"SQLITE": "🧱 SQLite",
|
||||
"REDIS": "🧠 Redis",
|
||||
"REMNAWAVE": "🌐 RemnaWave API",
|
||||
"SERVER_STATUS": "📊 Статус серверов",
|
||||
"MONITORING": "📈 Мониторинг",
|
||||
"MAINTENANCE": "🔧 Обслуживание",
|
||||
"BACKUP": "💾 Резервные копии",
|
||||
"VERSION": "🔄 Проверка версий",
|
||||
"WEB_API": "⚡ Web API",
|
||||
"WEBHOOK": "🌐 Webhook",
|
||||
"LOG": "📝 Логирование",
|
||||
"DEBUG": "🧪 Режим разработки",
|
||||
}
|
||||
|
||||
CATEGORY_DESCRIPTIONS: Dict[str, str] = {
|
||||
"CORE": "Базовые параметры работы бота и обязательные ссылки.",
|
||||
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
|
||||
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
|
||||
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"MULENPAY": "Платежи MulenPay и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
"TELEGRAM": "Telegram Stars и их стоимость.",
|
||||
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
|
||||
"PERIODS": "Доступные периоды подписок и продлений.",
|
||||
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
|
||||
"TRAFFIC": "Лимиты трафика и стратегии сброса.",
|
||||
"TRAFFIC_PACKAGES": "Цены пакетов трафика и конфигурация предложений.",
|
||||
"TRIAL": "Длительность и ограничения пробного периода.",
|
||||
"REFERRAL": "Бонусы и пороги реферальной программы.",
|
||||
"AUTOPAY": "Настройки автопродления и минимальный баланс.",
|
||||
"NOTIFICATIONS": "Пользовательские уведомления и кэширование сообщений.",
|
||||
"ADMIN_NOTIFICATIONS": "Оповещения админам о событиях и тикетах.",
|
||||
"ADMIN_REPORTS": "Автоматические отчеты для команды.",
|
||||
"INTERFACE": "Глобальные параметры интерфейса и брендирования.",
|
||||
"INTERFACE_BRANDING": "Логотип и фирменный стиль.",
|
||||
"INTERFACE_SUBSCRIPTION": "Отображение ссылок и кнопок подписок.",
|
||||
"CONNECT_BUTTON": "Поведение кнопки «Подключиться» и miniapp.",
|
||||
"MINIAPP": "Mini App и кастомные ссылки.",
|
||||
"HAPP": "Интеграция Happ и связанные ссылки.",
|
||||
"SKIP": "Настройки быстрого старта и гайд по подключению.",
|
||||
"ADDITIONAL": "Конфигурация app-config.json, deep links и кеша.",
|
||||
"DATABASE": "Режим работы базы данных и пути до файлов.",
|
||||
"POSTGRES": "Параметры подключения к PostgreSQL.",
|
||||
"SQLITE": "Файл SQLite и резервные параметры.",
|
||||
"REDIS": "Подключение к Redis для кэша.",
|
||||
"REMNAWAVE": "Параметры авторизации и интеграция с RemnaWave API.",
|
||||
"SERVER_STATUS": "Отображение статуса серверов и external URL.",
|
||||
"MONITORING": "Интервалы мониторинга и хранение логов.",
|
||||
"MAINTENANCE": "Режим обслуживания, сообщения и интервалы.",
|
||||
"BACKUP": "Резервное копирование и расписание.",
|
||||
"VERSION": "Отслеживание обновлений репозитория.",
|
||||
"WEB_API": "Web API, токены и права доступа.",
|
||||
"WEBHOOK": "Пути и секреты вебхуков.",
|
||||
"LOG": "Уровни логирования и ротация.",
|
||||
"DEBUG": "Отладочные функции и безопасный режим.",
|
||||
}
|
||||
|
||||
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
|
||||
"DATABASE_URL": "DATABASE",
|
||||
"DATABASE_MODE": "DATABASE",
|
||||
"LOCALES_PATH": "DATABASE",
|
||||
"DEFAULT_DEVICE_LIMIT": "PAID_SUBSCRIPTION",
|
||||
"DEFAULT_TRAFFIC_LIMIT_GB": "PAID_SUBSCRIPTION",
|
||||
"MAX_DEVICES_LIMIT": "PAID_SUBSCRIPTION",
|
||||
"PRICE_PER_DEVICE": "PAID_SUBSCRIPTION",
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": "SUBSCRIPTIONS_GLOBAL",
|
||||
"LOCALES_PATH": "LOCALIZATION",
|
||||
"CHANNEL_SUB_ID": "CHANNEL",
|
||||
"CHANNEL_LINK": "CHANNEL",
|
||||
"CHANNEL_IS_REQUIRED_SUB": "CHANNEL",
|
||||
"DEFAULT_LANGUAGE": "LOCALIZATION",
|
||||
"AVAILABLE_LANGUAGES": "LOCALIZATION",
|
||||
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
|
||||
"DEFAULT_DEVICE_LIMIT": "SUBSCRIPTIONS_CORE",
|
||||
"DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE",
|
||||
"MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE",
|
||||
"PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
|
||||
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
|
||||
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
|
||||
"FIXED_TRAFFIC_LIMIT_GB": "TRAFFIC",
|
||||
"AVAILABLE_SUBSCRIPTION_PERIODS": "PERIODS",
|
||||
"AVAILABLE_RENEWAL_PERIODS": "PERIODS",
|
||||
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_14_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_30_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_60_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_TRAFFIC_5GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_10GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_25GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_50GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_100GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_250GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_500GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_1000GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_UNLIMITED": "TRAFFIC_PACKAGES",
|
||||
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "DISCOUNTS",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "DISCOUNTS",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
|
||||
"REFERRED_USER_REWARD": "REFERRAL",
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
"TRIAL_WARNING_HOURS": "NOTIFICATIONS",
|
||||
"TRIAL_WARNING_HOURS": "TRIAL",
|
||||
"SUPPORT_USERNAME": "SUPPORT",
|
||||
"SUPPORT_MENU_ENABLED": "SUPPORT",
|
||||
"SUPPORT_SYSTEM_MODE": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_ENABLED": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_MINUTES": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES": "SUPPORT",
|
||||
"ADMIN_NOTIFICATIONS_ENABLED": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_NOTIFICATIONS_CHAT_ID": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_NOTIFICATIONS_TOPIC_ID": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_REPORTS_ENABLED": "ADMIN_REPORTS",
|
||||
"ADMIN_REPORTS_CHAT_ID": "ADMIN_REPORTS",
|
||||
"ADMIN_REPORTS_TOPIC_ID": "ADMIN_REPORTS",
|
||||
"ADMIN_REPORTS_SEND_TIME": "ADMIN_REPORTS",
|
||||
"PAYMENT_SERVICE_NAME": "PAYMENT",
|
||||
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
|
||||
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
|
||||
"MONITORING_INTERVAL": "MONITORING",
|
||||
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
|
||||
"LOGO_FILE": "INTERFACE_BRANDING",
|
||||
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
|
||||
@@ -150,14 +220,17 @@ class BotConfigurationService:
|
||||
"APP_CONFIG_PATH": "ADDITIONAL",
|
||||
"ENABLE_DEEP_LINKS": "ADDITIONAL",
|
||||
"APP_CONFIG_CACHE_TTL": "ADDITIONAL",
|
||||
"DEFAULT_LANGUAGE": "LOCALIZATION",
|
||||
"AVAILABLE_LANGUAGES": "LOCALIZATION",
|
||||
"PAYMENT_SERVICE_NAME": "PAYMENT",
|
||||
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"INACTIVE_USER_DELETE_MONTHS": "MONITORING",
|
||||
"INACTIVE_USER_DELETE_MONTHS": "MAINTENANCE",
|
||||
"MAINTENANCE_MESSAGE": "MAINTENANCE",
|
||||
"MAINTENANCE_CHECK_INTERVAL": "MAINTENANCE",
|
||||
"MAINTENANCE_AUTO_ENABLE": "MAINTENANCE",
|
||||
"WEBHOOK_URL": "WEBHOOK",
|
||||
"WEBHOOK_SECRET": "WEBHOOK",
|
||||
"VERSION_CHECK_ENABLED": "VERSION",
|
||||
"VERSION_CHECK_REPO": "VERSION",
|
||||
"VERSION_CHECK_INTERVAL_HOURS": "VERSION",
|
||||
"TELEGRAM_STARS_RATE_RUB": "TELEGRAM",
|
||||
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE",
|
||||
}
|
||||
|
||||
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
|
||||
@@ -185,14 +258,16 @@ class BotConfigurationService:
|
||||
"CONNECT_BUTTON_HAPP": "HAPP",
|
||||
"HAPP_": "HAPP",
|
||||
"SKIP_": "SKIP",
|
||||
"MINIAPP_": "MINIAPP",
|
||||
"MONITORING_": "MONITORING",
|
||||
"NOTIFICATION_": "NOTIFICATIONS",
|
||||
"SERVER_STATUS": "SERVER",
|
||||
"SERVER_STATUS": "SERVER_STATUS",
|
||||
"MAINTENANCE_": "MAINTENANCE",
|
||||
"VERSION_CHECK": "VERSION",
|
||||
"BACKUP_": "BACKUP",
|
||||
"WEBHOOK_": "WEBHOOK",
|
||||
"LOG_": "LOG",
|
||||
"WEB_API_": "WEB_API",
|
||||
"DEBUG": "DEBUG",
|
||||
}
|
||||
|
||||
@@ -283,6 +358,153 @@ class BotConfigurationService:
|
||||
],
|
||||
}
|
||||
|
||||
SETTING_HINTS: Dict[str, Dict[str, str]] = {
|
||||
"YOOKASSA_ENABLED": {
|
||||
"description": "Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.",
|
||||
"format": "Булево значение: выберите \"Включить\" или \"Выключить\".",
|
||||
"example": "Включено при полностью настроенной интеграции.",
|
||||
"warning": "При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.",
|
||||
"dependencies": "YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL",
|
||||
},
|
||||
"CRYPTOBOT_ENABLED": {
|
||||
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
|
||||
"format": "Булево значение.",
|
||||
"example": "Включите после указания токена API и секрета вебхука.",
|
||||
"warning": "Пустой токен или неверный вебхук приведут к отказам платежей.",
|
||||
"dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET",
|
||||
},
|
||||
"SUPPORT_TICKET_SLA_MINUTES": {
|
||||
"description": "Лимит времени для ответа модераторов на тикет в минутах.",
|
||||
"format": "Целое число от 1 до 1440.",
|
||||
"example": "5",
|
||||
"warning": "Слишком низкое значение может вызвать частые напоминания, слишком высокое — ухудшить SLA.",
|
||||
"dependencies": "SUPPORT_TICKET_SLA_ENABLED, SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES",
|
||||
},
|
||||
"MAINTENANCE_MODE": {
|
||||
"description": "Переводит бота в режим технического обслуживания и скрывает действия для пользователей.",
|
||||
"format": "Булево значение.",
|
||||
"example": "Включено на время плановых работ.",
|
||||
"warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.",
|
||||
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"REMNAWAVE_API_URL": {
|
||||
"description": "Базовый адрес панели RemnaWave, с которой синхронизируется бот.",
|
||||
"format": "Полный URL вида https://panel.example.com.",
|
||||
"example": "https://panel.remnawave.net",
|
||||
"warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.",
|
||||
"dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_category_description(cls, category_key: str) -> str:
|
||||
return cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
|
||||
|
||||
@classmethod
|
||||
def is_toggle(cls, key: str) -> bool:
|
||||
definition = cls.get_definition(key)
|
||||
return definition.python_type is bool
|
||||
|
||||
@classmethod
|
||||
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
upper_key = key.upper()
|
||||
if any(suffix in upper_key for suffix in ("PRICE", "_KOPEKS", "AMOUNT")):
|
||||
try:
|
||||
return settings.format_price(int(value))
|
||||
except Exception:
|
||||
return f"{value}"
|
||||
if upper_key.endswith("_PERCENT") or "PERCENT" in upper_key:
|
||||
return f"{value}%"
|
||||
if upper_key.endswith("_HOURS"):
|
||||
return f"{value} ч"
|
||||
if upper_key.endswith("_MINUTES"):
|
||||
return f"{value} мин"
|
||||
if upper_key.endswith("_SECONDS"):
|
||||
return f"{value} сек"
|
||||
if upper_key.endswith("_DAYS"):
|
||||
return f"{value} дн"
|
||||
if upper_key.endswith("_GB"):
|
||||
return f"{value} ГБ"
|
||||
if upper_key.endswith("_MB"):
|
||||
return f"{value} МБ"
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _split_comma_values(cls, text: str) -> Optional[List[str]]:
|
||||
raw = (text or "").strip()
|
||||
if not raw or "," not in raw:
|
||||
return None
|
||||
parts = [segment.strip() for segment in raw.split(",") if segment.strip()]
|
||||
return parts or None
|
||||
|
||||
@classmethod
|
||||
def format_value_human(cls, key: str, value: Any) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
if isinstance(value, bool):
|
||||
return "✅ ВКЛЮЧЕНО" if value else "❌ ВЫКЛЮЧЕНО"
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
formatted = cls._format_numeric_with_unit(key, value)
|
||||
return formatted or str(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
return "—"
|
||||
if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")):
|
||||
return "••••••••"
|
||||
items = cls._split_comma_values(cleaned)
|
||||
if items:
|
||||
return ", ".join(items)
|
||||
return cleaned
|
||||
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return ", ".join(str(item) for item in value)
|
||||
|
||||
if isinstance(value, dict):
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_setting_guidance(cls, key: str) -> Dict[str, str]:
|
||||
definition = cls.get_definition(key)
|
||||
original = cls.get_original_value(key)
|
||||
type_label = definition.type_label
|
||||
hints = dict(cls.SETTING_HINTS.get(key, {}))
|
||||
|
||||
base_description = (
|
||||
hints.get("description")
|
||||
or f"Параметр <b>{definition.display_name}</b> управляет категорией «{definition.category_label}»."
|
||||
)
|
||||
base_format = hints.get("format") or (
|
||||
"Булево значение (да/нет)." if definition.python_type is bool
|
||||
else "Введите значение соответствующего типа (число или строку)."
|
||||
)
|
||||
example = hints.get("example") or (
|
||||
cls.format_value_human(key, original) if original is not None else "—"
|
||||
)
|
||||
warning = hints.get("warning") or (
|
||||
"Неверные значения могут привести к некорректной работе бота."
|
||||
)
|
||||
dependencies = hints.get("dependencies") or definition.category_label
|
||||
|
||||
return {
|
||||
"description": base_description,
|
||||
"format": base_format,
|
||||
"example": example,
|
||||
"warning": warning,
|
||||
"dependencies": dependencies,
|
||||
"type": type_label,
|
||||
}
|
||||
|
||||
_definitions: Dict[str, SettingDefinition] = {}
|
||||
_original_values: Dict[str, Any] = settings.model_dump()
|
||||
_overrides_raw: Dict[str, Optional[str]] = {}
|
||||
@@ -436,7 +658,7 @@ class BotConfigurationService:
|
||||
@classmethod
|
||||
def format_value_for_list(cls, key: str) -> str:
|
||||
value = cls.get_current_value(key)
|
||||
formatted = cls.format_value(value)
|
||||
formatted = cls.format_value_human(key, value)
|
||||
if formatted == "—":
|
||||
return formatted
|
||||
return _truncate(formatted)
|
||||
@@ -541,6 +763,8 @@ class BotConfigurationService:
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, parsed_value)
|
||||
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
async def reload(cls) -> None:
|
||||
cls._overrides_raw.clear()
|
||||
@@ -642,6 +866,9 @@ class BotConfigurationService:
|
||||
cls._overrides_raw[key] = raw_value
|
||||
cls._apply_to_settings(key, value)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
async def reset_value(cls, db: AsyncSession, key: str) -> None:
|
||||
await delete_system_setting(db, key)
|
||||
@@ -649,13 +876,39 @@ class BotConfigurationService:
|
||||
original = cls.get_original_value(key)
|
||||
cls._apply_to_settings(key, original)
|
||||
|
||||
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
def _apply_to_settings(cls, key: str, value: Any) -> None:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
if key in {
|
||||
"PRICE_14_DAYS",
|
||||
"PRICE_30_DAYS",
|
||||
"PRICE_60_DAYS",
|
||||
"PRICE_90_DAYS",
|
||||
"PRICE_180_DAYS",
|
||||
"PRICE_360_DAYS",
|
||||
}:
|
||||
refresh_period_prices()
|
||||
elif key.startswith("PRICE_TRAFFIC_") or key == "TRAFFIC_PACKAGES_CONFIG":
|
||||
refresh_traffic_prices()
|
||||
except Exception as error:
|
||||
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
|
||||
|
||||
@staticmethod
|
||||
async def _sync_default_web_api_token() -> None:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if not default_token:
|
||||
return
|
||||
|
||||
success = await ensure_default_web_api_token()
|
||||
if not success:
|
||||
logger.warning(
|
||||
"Не удалось синхронизировать бутстрап токен веб-API после обновления настроек",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_setting_summary(cls, key: str) -> Dict[str, Any]:
|
||||
definition = cls.get_definition(key)
|
||||
@@ -666,8 +919,8 @@ class BotConfigurationService:
|
||||
return {
|
||||
"key": key,
|
||||
"name": definition.display_name,
|
||||
"current": cls.format_value(current),
|
||||
"original": cls.format_value(original),
|
||||
"current": cls.format_value_human(key, current),
|
||||
"original": cls.format_value_human(key, original),
|
||||
"type": definition.type_label,
|
||||
"category_key": definition.category_key,
|
||||
"category_label": definition.category_label,
|
||||
|
||||
+139
-13
@@ -2,13 +2,14 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy import delete, select, update, func
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user,
|
||||
get_users_spending_stats
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
@@ -18,7 +19,8 @@ from app.database.models import (
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
|
||||
AdvertisingCampaign
|
||||
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
||||
TransactionType
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -141,20 +143,32 @@ class UserService:
|
||||
"has_next": False,
|
||||
"has_prev": False
|
||||
}
|
||||
|
||||
|
||||
async def get_users_page(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
status: Optional[UserStatus] = None,
|
||||
order_by_balance: bool = False
|
||||
order_by_balance: bool = False,
|
||||
order_by_traffic: bool = False,
|
||||
order_by_last_activity: bool = False,
|
||||
order_by_total_spent: bool = False,
|
||||
order_by_purchase_count: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
users = await get_users_list(
|
||||
db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
status=status,
|
||||
order_by_balance=order_by_balance,
|
||||
order_by_traffic=order_by_traffic,
|
||||
order_by_last_activity=order_by_last_activity,
|
||||
order_by_total_spent=order_by_total_spent,
|
||||
order_by_purchase_count=order_by_purchase_count,
|
||||
)
|
||||
total_count = await get_users_count(db, status=status)
|
||||
|
||||
@@ -179,7 +193,110 @@ class UserService:
|
||||
"has_next": False,
|
||||
"has_prev": False
|
||||
}
|
||||
|
||||
|
||||
async def get_user_spending_stats_map(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_ids: List[int]
|
||||
) -> Dict[int, Dict[str, int]]:
|
||||
try:
|
||||
return await get_users_spending_stats(db, user_ids)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики трат пользователей: {e}")
|
||||
return {}
|
||||
|
||||
async def get_users_by_campaign_page(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 20
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
campaign_ranked = (
|
||||
select(
|
||||
AdvertisingCampaignRegistration.user_id.label("user_id"),
|
||||
AdvertisingCampaignRegistration.campaign_id.label("campaign_id"),
|
||||
AdvertisingCampaignRegistration.created_at.label("created_at"),
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=AdvertisingCampaignRegistration.user_id,
|
||||
order_by=AdvertisingCampaignRegistration.created_at.desc(),
|
||||
)
|
||||
.label("rn"),
|
||||
)
|
||||
.cte("campaign_ranked")
|
||||
)
|
||||
|
||||
latest_campaign = (
|
||||
select(
|
||||
campaign_ranked.c.user_id,
|
||||
campaign_ranked.c.campaign_id,
|
||||
campaign_ranked.c.created_at,
|
||||
)
|
||||
.where(campaign_ranked.c.rn == 1)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
AdvertisingCampaign.name.label("campaign_name"),
|
||||
latest_campaign.c.created_at,
|
||||
)
|
||||
.join(latest_campaign, latest_campaign.c.user_id == User.id)
|
||||
.join(
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaign.id == latest_campaign.c.campaign_id,
|
||||
)
|
||||
.order_by(
|
||||
AdvertisingCampaign.name.asc(),
|
||||
latest_campaign.c.created_at.desc(),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
users = [row[0] for row in rows]
|
||||
campaign_map = {
|
||||
row[0].id: {
|
||||
"campaign_name": row[1],
|
||||
"registered_at": row[2],
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
total_stmt = select(func.count()).select_from(latest_campaign)
|
||||
total_result = await db.execute(total_stmt)
|
||||
total_count = total_result.scalar() or 0
|
||||
total_pages = (total_count + limit - 1) // limit if total_count else 1
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"campaigns": campaign_map,
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"total_count": total_count,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения пользователей по кампаниям: {e}")
|
||||
return {
|
||||
"users": [],
|
||||
"campaigns": {},
|
||||
"current_page": 1,
|
||||
"total_pages": 1,
|
||||
"total_count": 0,
|
||||
"has_next": False,
|
||||
"has_prev": False,
|
||||
}
|
||||
|
||||
async def update_user_balance(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -203,7 +320,14 @@ class UserService:
|
||||
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
|
||||
success = True
|
||||
else:
|
||||
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
abs(amount_kopeks),
|
||||
description,
|
||||
create_transaction=True,
|
||||
payment_method=PaymentMethod.MANUAL,
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽")
|
||||
|
||||
@@ -231,15 +355,17 @@ class UserService:
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> Tuple[bool, Optional[User], Optional[PromoGroup]]:
|
||||
) -> Tuple[bool, Optional[User], Optional[PromoGroup], Optional[PromoGroup]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False, None, None
|
||||
return False, None, None, None
|
||||
|
||||
old_group = user.promo_group
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, promo_group_id)
|
||||
if not promo_group:
|
||||
return False, None, None
|
||||
return False, None, None, old_group
|
||||
|
||||
user.promo_group_id = promo_group.id
|
||||
user.promo_group = promo_group
|
||||
@@ -254,12 +380,12 @@ class UserService:
|
||||
promo_group.name,
|
||||
)
|
||||
|
||||
return True, user, promo_group
|
||||
return True, user, promo_group, old_group
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
|
||||
return False, None, None
|
||||
return False, None, None, None
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud import web_api_token as crud
|
||||
from app.database.models import WebApiToken
|
||||
from app.database.universal_migration import ensure_default_web_api_token
|
||||
from app.utils.security import generate_api_token, hash_api_token
|
||||
|
||||
|
||||
class WebApiTokenService:
|
||||
"""Сервис для управления токенами административного веб-API."""
|
||||
|
||||
def __init__(self):
|
||||
self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or "sha256"
|
||||
|
||||
def hash_token(self, token: str) -> str:
|
||||
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
token_value: str,
|
||||
*,
|
||||
remote_ip: Optional[str] = None,
|
||||
) -> Optional[WebApiToken]:
|
||||
normalized_value = token_value.strip()
|
||||
if not normalized_value:
|
||||
return None
|
||||
|
||||
async def _load_token(value: str) -> Optional[WebApiToken]:
|
||||
token_hash = self.hash_token(value)
|
||||
return await crud.get_token_by_hash(db, token_hash)
|
||||
|
||||
token = await _load_token(normalized_value)
|
||||
|
||||
if not token:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if default_token and secrets.compare_digest(default_token, normalized_value):
|
||||
await ensure_default_web_api_token()
|
||||
token = await _load_token(default_token)
|
||||
|
||||
if not token or not token.is_active:
|
||||
return None
|
||||
|
||||
if token.expires_at and token.expires_at < datetime.utcnow():
|
||||
return None
|
||||
|
||||
token.last_used_at = datetime.utcnow()
|
||||
if remote_ip:
|
||||
token.last_used_ip = remote_ip
|
||||
await db.flush()
|
||||
return token
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
created_by: Optional[str] = None,
|
||||
token_value: Optional[str] = None,
|
||||
) -> Tuple[str, WebApiToken]:
|
||||
plain_token = token_value or generate_api_token()
|
||||
token_hash = self.hash_token(plain_token)
|
||||
|
||||
token = await crud.create_token(
|
||||
db,
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=plain_token[:12],
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
return plain_token, token
|
||||
|
||||
async def revoke_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
|
||||
token.is_active = False
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
async def activate_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
|
||||
token.is_active = True
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
web_api_token_service = WebApiTokenService()
|
||||
@@ -1,6 +1,7 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
class RegistrationStates(StatesGroup):
|
||||
waiting_for_language = State()
|
||||
waiting_for_rules_accept = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
@@ -107,6 +108,11 @@ class AdminStates(StatesGroup):
|
||||
|
||||
# Состояния для отслеживания источника перехода
|
||||
viewing_user_from_balance_list = State()
|
||||
viewing_user_from_traffic_list = State()
|
||||
viewing_user_from_last_activity_list = State()
|
||||
viewing_user_from_spending_list = State()
|
||||
viewing_user_from_purchases_list = State()
|
||||
viewing_user_from_campaign_list = State()
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
@@ -126,6 +132,12 @@ class SupportSettingsStates(StatesGroup):
|
||||
|
||||
class BotConfigStates(StatesGroup):
|
||||
waiting_for_value = State()
|
||||
waiting_for_search_query = State()
|
||||
waiting_for_import_file = State()
|
||||
|
||||
|
||||
class PricingStates(StatesGroup):
|
||||
waiting_for_value = State()
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
|
||||
@@ -8,22 +8,28 @@ from app.states import RegistrationStates
|
||||
|
||||
def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool:
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_language.state,
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
registration_callbacks = [
|
||||
"rules_accept",
|
||||
"rules_decline",
|
||||
"rules_accept",
|
||||
"rules_decline",
|
||||
"referral_skip"
|
||||
]
|
||||
|
||||
language_select_prefix = "language_select:"
|
||||
|
||||
if current_state in registration_states:
|
||||
return True
|
||||
|
||||
if (isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and event.data in registration_callbacks):
|
||||
if (isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (
|
||||
event.data in registration_callbacks
|
||||
or event.data.startswith(language_select_prefix)
|
||||
)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
+44
-25
@@ -28,7 +28,7 @@ def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str:
|
||||
return dt.strftime(format_str)
|
||||
|
||||
|
||||
def format_time_ago(dt: Union[datetime, str]) -> str:
|
||||
def format_time_ago(dt: Union[datetime, str], language: str = "ru") -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
@@ -40,32 +40,51 @@ def format_time_ago(dt: Union[datetime, str]) -> str:
|
||||
|
||||
now = datetime.utcnow()
|
||||
diff = now - dt
|
||||
|
||||
|
||||
language_code = (language or "ru").split("-")[0].lower()
|
||||
|
||||
if diff.days > 0:
|
||||
if diff.days == 1:
|
||||
return "вчера"
|
||||
elif diff.days < 7:
|
||||
return f"{diff.days} дн. назад"
|
||||
elif diff.days < 30:
|
||||
weeks = diff.days // 7
|
||||
return f"{weeks} нед. назад"
|
||||
elif diff.days < 365:
|
||||
months = diff.days // 30
|
||||
return f"{months} мес. назад"
|
||||
else:
|
||||
years = diff.days // 365
|
||||
return f"{years} г. назад"
|
||||
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} ч. назад"
|
||||
|
||||
elif diff.seconds > 60:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} мин. назад"
|
||||
|
||||
else:
|
||||
return "только что"
|
||||
return "yesterday" if language_code == "en" else "вчера"
|
||||
if diff.days < 7:
|
||||
value = diff.days
|
||||
if language_code == "en":
|
||||
suffix = "day" if value == 1 else "days"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} дн. назад"
|
||||
if diff.days < 30:
|
||||
value = diff.days // 7
|
||||
if language_code == "en":
|
||||
suffix = "week" if value == 1 else "weeks"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} нед. назад"
|
||||
if diff.days < 365:
|
||||
value = diff.days // 30
|
||||
if language_code == "en":
|
||||
suffix = "month" if value == 1 else "months"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} мес. назад"
|
||||
value = diff.days // 365
|
||||
if language_code == "en":
|
||||
suffix = "year" if value == 1 else "years"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} г. назад"
|
||||
|
||||
if diff.seconds > 3600:
|
||||
value = diff.seconds // 3600
|
||||
if language_code == "en":
|
||||
suffix = "hour" if value == 1 else "hours"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} ч. назад"
|
||||
|
||||
if diff.seconds > 60:
|
||||
value = diff.seconds // 60
|
||||
if language_code == "en":
|
||||
suffix = "minute" if value == 1 else "minutes"
|
||||
return f"{value} {suffix} ago"
|
||||
return f"{value} мин. назад"
|
||||
|
||||
return "just now" if language_code == "en" else "только что"
|
||||
|
||||
def format_days_declension(days: int, language: str = "ru") -> str:
|
||||
if language != "ru":
|
||||
|
||||
@@ -27,6 +27,7 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
pass
|
||||
if LOGO_PATH.exists():
|
||||
try:
|
||||
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
except Exception:
|
||||
# Фоллбек, если Telegram ругается на caption: отправим как текст
|
||||
|
||||
@@ -48,8 +48,8 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
if settings.is_pal24_enabled():
|
||||
methods.append({
|
||||
"id": "pal24",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"name": "СБП",
|
||||
"icon": "🏦",
|
||||
"description": "через PayPalych",
|
||||
"callback": "topup_pal24"
|
||||
})
|
||||
|
||||
@@ -50,6 +50,13 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
|
||||
discount_value = amount * clamped_percent // 100
|
||||
discounted_amount = amount - discount_value
|
||||
|
||||
# Round the discounted price up to the nearest full ruble (100 kopeks)
|
||||
# to avoid undercharging users because of fractional kopeks.
|
||||
if discount_value >= 100 and discounted_amount % 100:
|
||||
discounted_amount += 100 - (discounted_amount % 100)
|
||||
discounted_amount = min(discounted_amount, amount)
|
||||
discount_value = amount - discounted_amount
|
||||
|
||||
logger.debug(
|
||||
"Применена скидка %s%%: %s → %s (скидка %s)",
|
||||
clamped_percent,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Утилиты безопасности и генерации ключей."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import Literal
|
||||
|
||||
|
||||
HashAlgorithm = Literal["sha256", "sha384", "sha512"]
|
||||
|
||||
|
||||
def hash_api_token(token: str, algorithm: HashAlgorithm = "sha256") -> str:
|
||||
"""Возвращает хеш токена в формате hex."""
|
||||
normalized = (algorithm or "sha256").lower()
|
||||
if normalized not in {"sha256", "sha384", "sha512"}:
|
||||
raise ValueError(f"Unsupported hash algorithm: {algorithm}")
|
||||
|
||||
digest = getattr(hashlib, normalized)
|
||||
return digest(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_api_token(length: int = 48) -> str:
|
||||
"""Генерирует криптографически стойкий токен."""
|
||||
length = max(24, min(length, 128))
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
|
||||
__all__ = ["hash_api_token", "generate_api_token", "HashAlgorithm"]
|
||||
@@ -0,0 +1,184 @@
|
||||
import platform
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepRecord:
|
||||
title: str
|
||||
icon: str
|
||||
status_label: str
|
||||
message: str
|
||||
duration: float
|
||||
|
||||
|
||||
class StageHandle:
|
||||
def __init__(
|
||||
self,
|
||||
timeline: "StartupTimeline",
|
||||
title: str,
|
||||
icon: str,
|
||||
success_message: Optional[str],
|
||||
) -> None:
|
||||
self.timeline = timeline
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.message = success_message or ""
|
||||
self.status_icon = "✅"
|
||||
self.status_label = "Готово"
|
||||
self._explicit_status = False
|
||||
|
||||
def success(self, message: Optional[str] = None) -> None:
|
||||
if message is not None:
|
||||
self.message = message
|
||||
self.status_icon = "✅"
|
||||
self.status_label = "Готово"
|
||||
self._explicit_status = True
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
self.status_icon = "⚠️"
|
||||
self.status_label = "Предупреждение"
|
||||
self.message = message
|
||||
self._explicit_status = True
|
||||
|
||||
def skip(self, message: str) -> None:
|
||||
self.status_icon = "⏭️"
|
||||
self.status_label = "Пропущено"
|
||||
self.message = message
|
||||
self._explicit_status = True
|
||||
|
||||
def failure(self, message: str) -> None:
|
||||
self.status_icon = "❌"
|
||||
self.status_label = "Ошибка"
|
||||
self.message = message
|
||||
self._explicit_status = True
|
||||
|
||||
def log(self, message: str, icon: str = "•") -> None:
|
||||
self.timeline.logger.info(f"┃ {icon} {message}")
|
||||
|
||||
|
||||
class StartupTimeline:
|
||||
def __init__(self, logger: Any, app_name: str) -> None:
|
||||
self.logger = logger
|
||||
self.app_name = app_name
|
||||
self.steps: List[StepRecord] = []
|
||||
|
||||
def _record_step(
|
||||
self, title: str, icon: str, status_label: str, message: str, duration: float
|
||||
) -> None:
|
||||
self.steps.append(
|
||||
StepRecord(
|
||||
title=title,
|
||||
icon=icon,
|
||||
status_label=status_label,
|
||||
message=message,
|
||||
duration=duration,
|
||||
)
|
||||
)
|
||||
|
||||
def log_banner(self, metadata: Optional[Sequence[Tuple[str, Any]]] = None) -> None:
|
||||
title_text = f"🚀 {self.app_name}"
|
||||
subtitle_parts = [f"Python {platform.python_version()}"]
|
||||
if metadata:
|
||||
for key, value in metadata:
|
||||
subtitle_parts.append(f"{key}: {value}")
|
||||
subtitle_text = " | ".join(subtitle_parts)
|
||||
|
||||
width = max(len(title_text), len(subtitle_text))
|
||||
border = "╔" + "═" * (width + 2) + "╗"
|
||||
self.logger.info(border)
|
||||
self.logger.info("║ " + title_text.ljust(width) + " ║")
|
||||
self.logger.info("║ " + subtitle_text.ljust(width) + " ║")
|
||||
self.logger.info("╚" + "═" * (width + 2) + "╝")
|
||||
|
||||
def log_section(self, title: str, lines: Iterable[str], icon: str = "📄") -> None:
|
||||
items = [f"{icon} {title}"] + [f"• {line}" for line in lines]
|
||||
width = max(len(item) for item in items)
|
||||
top = "┌ " + "─" * width + " ┐"
|
||||
middle = "├ " + "─" * width + " ┤"
|
||||
bottom = "└ " + "─" * width + " ┘"
|
||||
|
||||
self.logger.info(top)
|
||||
self.logger.info("│ " + items[0].ljust(width) + " │")
|
||||
self.logger.info(middle)
|
||||
for item in items[1:]:
|
||||
self.logger.info("│ " + item.ljust(width) + " │")
|
||||
self.logger.info(bottom)
|
||||
|
||||
def add_manual_step(
|
||||
self,
|
||||
title: str,
|
||||
icon: str,
|
||||
status_label: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
self.logger.info(f"┏ {icon} {title}")
|
||||
self.logger.info(f"┗ {icon} {title} — {status_label}: {message}")
|
||||
self._record_step(title, icon, status_label, message, 0.0)
|
||||
|
||||
@asynccontextmanager
|
||||
async def stage(
|
||||
self,
|
||||
title: str,
|
||||
icon: str = "⚙️",
|
||||
description: Optional[str] = None,
|
||||
success_message: Optional[str] = "Готово",
|
||||
):
|
||||
if description:
|
||||
self.logger.info(f"┏ {icon} {title} — {description}")
|
||||
else:
|
||||
self.logger.info(f"┏ {icon} {title}")
|
||||
|
||||
handle = StageHandle(self, title, icon, success_message)
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
yield handle
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
handle.failure(message)
|
||||
self.logger.exception(f"┣ ❌ {title} — ошибка: {message}")
|
||||
raise
|
||||
finally:
|
||||
duration = time.perf_counter() - start_time
|
||||
if not handle._explicit_status:
|
||||
handle.success(handle.message or "Готово")
|
||||
self.logger.info(
|
||||
f"┗ {handle.status_icon} {title} — {handle.message} [{duration:.2f}s]"
|
||||
)
|
||||
self._record_step(
|
||||
title=title,
|
||||
icon=handle.status_icon,
|
||||
status_label=handle.status_label,
|
||||
message=handle.message,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
def log_summary(self) -> None:
|
||||
if not self.steps:
|
||||
return
|
||||
|
||||
lines = []
|
||||
for step in self.steps:
|
||||
base = (
|
||||
f"{step.icon} {step.title} — {step.status_label}"
|
||||
f" [{step.duration:.2f}s]"
|
||||
)
|
||||
if step.message:
|
||||
base += f" :: {step.message}"
|
||||
lines.append(base)
|
||||
|
||||
width = max(len(line) for line in lines)
|
||||
border_top = "┏" + "━" * (width + 2) + "┓"
|
||||
border_mid = "┣" + "━" * (width + 2) + "┫"
|
||||
border_bottom = "┗" + "━" * (width + 2) + "┛"
|
||||
title = "РЕЗЮМЕ ЗАПУСКА"
|
||||
|
||||
self.logger.info(border_top)
|
||||
self.logger.info("┃ " + title.center(width) + " ┃")
|
||||
self.logger.info(border_mid)
|
||||
for line in lines:
|
||||
self.logger.info("┃ " + line.ljust(width) + " ┃")
|
||||
self.logger.info(border_bottom)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlparse, urlunparse
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.models import Subscription, User
|
||||
@@ -141,3 +141,18 @@ def get_happ_cryptolink_redirect_link(subscription_link: Optional[str]) -> Optio
|
||||
return f"{template}{encoded_link}"
|
||||
|
||||
return f"{template}{encoded_link}"
|
||||
|
||||
|
||||
def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) -> Optional[str]:
|
||||
if not subscription_link:
|
||||
return None
|
||||
|
||||
parsed_link = urlparse(subscription_link)
|
||||
|
||||
if parsed_link.scheme.lower() == "happ":
|
||||
return subscription_link
|
||||
|
||||
if not parsed_link.scheme:
|
||||
return subscription_link
|
||||
|
||||
return urlunparse(parsed_link._replace(scheme="happ"))
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Utilities for validating Telegram WebApp initialization data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
|
||||
class TelegramWebAppAuthError(Exception):
|
||||
"""Raised when Telegram WebApp init data fails validation."""
|
||||
|
||||
|
||||
def parse_webapp_init_data(
|
||||
init_data: str,
|
||||
bot_token: str,
|
||||
*,
|
||||
max_age_seconds: int = 86400,
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate and parse Telegram WebApp init data.
|
||||
|
||||
Args:
|
||||
init_data: Raw init data string provided by Telegram WebApp.
|
||||
bot_token: Bot token used to verify the signature.
|
||||
max_age_seconds: Maximum allowed age for the payload. Defaults to 24 hours.
|
||||
|
||||
Returns:
|
||||
Parsed init data as a dictionary.
|
||||
|
||||
Raises:
|
||||
TelegramWebAppAuthError: If validation fails.
|
||||
"""
|
||||
|
||||
if not init_data:
|
||||
raise TelegramWebAppAuthError("Missing init data")
|
||||
|
||||
if not bot_token:
|
||||
raise TelegramWebAppAuthError("Bot token is not configured")
|
||||
|
||||
parsed_pairs = parse_qsl(init_data, strict_parsing=True, keep_blank_values=True)
|
||||
data: Dict[str, Any] = {key: value for key, value in parsed_pairs}
|
||||
|
||||
received_hash = data.pop("hash", None)
|
||||
if not received_hash:
|
||||
raise TelegramWebAppAuthError("Missing init data signature")
|
||||
|
||||
data_check_string = "\n".join(
|
||||
f"{key}={value}" for key, value in sorted(data.items())
|
||||
)
|
||||
|
||||
secret_key = hmac.new(
|
||||
key=b"WebAppData",
|
||||
msg=bot_token.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
computed_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(computed_hash, received_hash):
|
||||
raise TelegramWebAppAuthError("Invalid init data signature")
|
||||
|
||||
auth_date_raw = data.get("auth_date")
|
||||
if auth_date_raw is not None:
|
||||
try:
|
||||
auth_date = int(auth_date_raw)
|
||||
except (TypeError, ValueError):
|
||||
raise TelegramWebAppAuthError("Invalid auth_date value") from None
|
||||
|
||||
if max_age_seconds and auth_date:
|
||||
current_ts = int(time.time())
|
||||
if current_ts - auth_date > max_age_seconds:
|
||||
raise TelegramWebAppAuthError("Init data is too old")
|
||||
|
||||
data["auth_date"] = auth_date
|
||||
|
||||
user_payload = data.get("user")
|
||||
if user_payload is not None:
|
||||
try:
|
||||
data["user"] = json.loads(user_payload)
|
||||
except json.JSONDecodeError as error:
|
||||
raise TelegramWebAppAuthError("Invalid user payload") from error
|
||||
|
||||
return data
|
||||
|
||||
@@ -189,7 +189,7 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int
|
||||
and_(
|
||||
Transaction.user_id == referral.id,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True
|
||||
Transaction.is_completed.is_(True)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -143,6 +143,23 @@ def sanitize_html(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def sanitize_telegram_name(name: Optional[str]) -> Optional[str]:
|
||||
"""Санитизация Telegram-имени для безопасной вставки в HTML и хранения.
|
||||
Заменяет угловые скобки и амперсанд на безопасные визуальные аналоги.
|
||||
"""
|
||||
if not name:
|
||||
return name
|
||||
try:
|
||||
return (
|
||||
name.replace('<', '‹')
|
||||
.replace('>', '›')
|
||||
.replace('&', '&')
|
||||
.strip()
|
||||
)
|
||||
except Exception:
|
||||
return name
|
||||
|
||||
|
||||
def validate_device_count(count: Union[str, int]) -> Optional[int]:
|
||||
try:
|
||||
count_int = int(count)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Пакет административного веб-API."""
|
||||
from .app import create_web_api_app
|
||||
from .server import WebAPIServer
|
||||
|
||||
__all__ = ["create_web_api_app", "WebAPIServer"]
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from .middleware import RequestLoggingMiddleware
|
||||
from .routes import (
|
||||
broadcasts,
|
||||
backups,
|
||||
campaigns,
|
||||
config,
|
||||
health,
|
||||
promocodes,
|
||||
miniapp,
|
||||
promo_groups,
|
||||
remnawave,
|
||||
stats,
|
||||
subscriptions,
|
||||
tickets,
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
)
|
||||
|
||||
|
||||
OPENAPI_TAGS = [
|
||||
{
|
||||
"name": "health",
|
||||
"description": "Мониторинг состояния административного API и связанных сервисов.",
|
||||
},
|
||||
{
|
||||
"name": "stats",
|
||||
"description": "Сводные показатели по пользователям, подпискам и платежам.",
|
||||
},
|
||||
{
|
||||
"name": "settings",
|
||||
"description": "Получение и изменение конфигурации бота из административной панели.",
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
"description": "Управление пользователями, балансом и статусами подписок.",
|
||||
},
|
||||
{
|
||||
"name": "subscriptions",
|
||||
"description": "Создание, продление и настройка подписок бота.",
|
||||
},
|
||||
{
|
||||
"name": "support",
|
||||
"description": "Работа с тикетами поддержки, приоритетами и ограничениями на ответы.",
|
||||
},
|
||||
{
|
||||
"name": "transactions",
|
||||
"description": "История финансовых операций и пополнений баланса.",
|
||||
},
|
||||
{
|
||||
"name": "promo-groups",
|
||||
"description": "Создание и управление промо-группами и их участниками.",
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": "Управление токенами доступа к административному API.",
|
||||
},
|
||||
{
|
||||
"name": "remnawave",
|
||||
"description": (
|
||||
"Интеграция с RemnaWave: статус панели, управление нодами, сквадами и синхронизацией "
|
||||
"данных между ботом и панелью."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "miniapp",
|
||||
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def create_web_api_app() -> FastAPI:
|
||||
docs_config = settings.get_web_api_docs_config()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.WEB_API_TITLE,
|
||||
version=settings.WEB_API_VERSION,
|
||||
docs_url=docs_config.get("docs_url"),
|
||||
redoc_url=docs_config.get("redoc_url"),
|
||||
openapi_url=docs_config.get("openapi_url"),
|
||||
openapi_tags=OPENAPI_TAGS,
|
||||
swagger_ui_parameters={"persistAuthorization": True},
|
||||
)
|
||||
|
||||
allowed_origins = settings.get_web_api_allowed_origins()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"] if allowed_origins == ["*"] else allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
if settings.WEB_API_REQUEST_LOGGING:
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||
app.include_router(config.router, prefix="/settings", tags=["settings"])
|
||||
app.include_router(users.router, prefix="/users", tags=["users"])
|
||||
app.include_router(subscriptions.router, prefix="/subscriptions", tags=["subscriptions"])
|
||||
app.include_router(tickets.router, prefix="/tickets", tags=["support"])
|
||||
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
|
||||
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
|
||||
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
|
||||
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
|
||||
app.include_router(backups.router, prefix="/backups", tags=["backups"])
|
||||
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
|
||||
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
||||
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
|
||||
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
|
||||
|
||||
return app
|
||||
@@ -0,0 +1 @@
|
||||
"""Background utilities for Web API."""
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.services.backup_service import backup_service
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BackupTaskState:
|
||||
task_id: str
|
||||
status: str = "queued"
|
||||
message: Optional[str] = None
|
||||
file_path: Optional[str] = None
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class BackupTaskManager:
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[str, BackupTaskState] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def enqueue(self, *, created_by: Optional[int]) -> BackupTaskState:
|
||||
task_id = uuid.uuid4().hex
|
||||
state = BackupTaskState(task_id=task_id, created_by=created_by)
|
||||
|
||||
async with self._lock:
|
||||
self._tasks[task_id] = state
|
||||
|
||||
asyncio.create_task(self._run_task(state))
|
||||
return state
|
||||
|
||||
async def _run_task(self, state: BackupTaskState) -> None:
|
||||
state.status = "running"
|
||||
state.updated_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
success, message, file_path = await backup_service.create_backup(
|
||||
created_by=state.created_by
|
||||
)
|
||||
state.message = message
|
||||
state.file_path = file_path
|
||||
state.status = "completed" if success else "failed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
state.status = "failed"
|
||||
state.message = f"Unexpected error: {exc}"
|
||||
finally:
|
||||
state.updated_at = datetime.utcnow()
|
||||
|
||||
async def get(self, task_id: str) -> Optional[BackupTaskState]:
|
||||
async with self._lock:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
async def list(self, *, active_only: bool = False) -> list[BackupTaskState]:
|
||||
async with self._lock:
|
||||
states = list(self._tasks.values())
|
||||
|
||||
if active_only:
|
||||
return [
|
||||
state
|
||||
for state in states
|
||||
if state.status in {"queued", "running"}
|
||||
]
|
||||
|
||||
return states
|
||||
|
||||
|
||||
backup_task_manager = BackupTaskManager()
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Security, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import WebApiToken
|
||||
from app.services.web_api_token_service import web_api_token_service
|
||||
|
||||
|
||||
api_key_header_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def require_api_token(
|
||||
request: Request,
|
||||
api_key_header: str | None = Security(api_key_header_scheme),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> WebApiToken:
|
||||
api_key = api_key_header
|
||||
|
||||
if not api_key:
|
||||
authorization = request.headers.get("Authorization")
|
||||
if authorization:
|
||||
scheme, _, credentials = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and credentials:
|
||||
api_key = credentials
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing API key",
|
||||
)
|
||||
|
||||
token = await web_api_token_service.authenticate(
|
||||
db,
|
||||
api_key,
|
||||
remote_ip=request.client.host if request.client else None,
|
||||
)
|
||||
|
||||
if not token:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired API key",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return token
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from time import monotonic
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
logger = logging.getLogger("web_api")
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Логирование входящих запросов в административный API."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
start = monotonic()
|
||||
response: Response | None = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
finally:
|
||||
duration_ms = (monotonic() - start) * 1000
|
||||
status = response.status_code if response else "error"
|
||||
logger.info(
|
||||
"%s %s -> %s (%.2f ms)",
|
||||
request.method,
|
||||
request.url.path,
|
||||
status,
|
||||
duration_ms,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
from . import (
|
||||
config,
|
||||
health,
|
||||
miniapp,
|
||||
promo_groups,
|
||||
remnawave,
|
||||
stats,
|
||||
subscriptions,
|
||||
tickets,
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"config",
|
||||
"health",
|
||||
"miniapp",
|
||||
"promo_groups",
|
||||
"remnawave",
|
||||
"stats",
|
||||
"subscriptions",
|
||||
"tickets",
|
||||
"tokens",
|
||||
"transactions",
|
||||
"users",
|
||||
]
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Security, status
|
||||
|
||||
from app.services.backup_service import backup_service
|
||||
|
||||
from ..background.backup_tasks import backup_task_manager
|
||||
from ..dependencies import require_api_token
|
||||
from ..schemas.backups import (
|
||||
BackupCreateResponse,
|
||||
BackupInfo,
|
||||
BackupListResponse,
|
||||
BackupStatusResponse,
|
||||
BackupTaskInfo,
|
||||
BackupTaskListResponse,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
if value.endswith("Z"):
|
||||
value = value.replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _to_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_backup(raw: dict) -> BackupInfo:
|
||||
timestamp = _parse_datetime(raw.get("timestamp"))
|
||||
tables_count = _to_int(raw.get("tables_count"))
|
||||
total_records = _to_int(raw.get("total_records"))
|
||||
file_size_bytes = _to_int(raw.get("file_size_bytes")) or 0
|
||||
file_size_mb = raw.get("file_size_mb")
|
||||
try:
|
||||
file_size_mb = float(file_size_mb)
|
||||
except (TypeError, ValueError):
|
||||
file_size_mb = round(file_size_bytes / 1024 / 1024, 2)
|
||||
|
||||
created_by = _to_int(raw.get("created_by"))
|
||||
|
||||
return BackupInfo(
|
||||
filename=str(raw.get("filename")),
|
||||
filepath=str(raw.get("filepath")),
|
||||
timestamp=timestamp,
|
||||
tables_count=tables_count,
|
||||
total_records=total_records,
|
||||
compressed=bool(raw.get("compressed", False)),
|
||||
file_size_bytes=file_size_bytes,
|
||||
file_size_mb=float(file_size_mb),
|
||||
created_by=created_by,
|
||||
database_type=raw.get("database_type"),
|
||||
version=raw.get("version"),
|
||||
error=raw.get("error"),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=BackupCreateResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
summary="Запустить создание резервной копии",
|
||||
)
|
||||
async def create_backup_endpoint(
|
||||
token: Any = Security(require_api_token),
|
||||
) -> BackupCreateResponse:
|
||||
created_by = getattr(token, "id", None)
|
||||
state = await backup_task_manager.enqueue(created_by=created_by)
|
||||
return BackupCreateResponse(task_id=state.task_id, status=state.status)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=BackupListResponse,
|
||||
summary="Список резервных копий",
|
||||
)
|
||||
async def list_backups(
|
||||
_: Any = Security(require_api_token),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> BackupListResponse:
|
||||
backups = await backup_service.get_backup_list()
|
||||
total = len(backups)
|
||||
|
||||
slice_backups = backups[offset : offset + limit]
|
||||
items = [_serialize_backup(raw) for raw in slice_backups]
|
||||
|
||||
return BackupListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status/{task_id}",
|
||||
response_model=BackupStatusResponse,
|
||||
summary="Статус создания резервной копии",
|
||||
)
|
||||
async def get_backup_status(
|
||||
task_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> BackupStatusResponse:
|
||||
state = await backup_task_manager.get(task_id)
|
||||
if not state:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Task not found")
|
||||
|
||||
return BackupStatusResponse(
|
||||
task_id=state.task_id,
|
||||
status=state.status,
|
||||
message=state.message,
|
||||
file_path=state.file_path,
|
||||
created_by=state.created_by,
|
||||
created_at=state.created_at,
|
||||
updated_at=state.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks",
|
||||
response_model=BackupTaskListResponse,
|
||||
summary="Список фоновых задач бекапов",
|
||||
)
|
||||
async def list_backup_tasks(
|
||||
_: Any = Security(require_api_token),
|
||||
active_only: bool = Query(False, description="Вернуть только активные задачи"),
|
||||
) -> BackupTaskListResponse:
|
||||
states = await backup_task_manager.list(active_only=active_only)
|
||||
|
||||
items = [
|
||||
BackupTaskInfo(
|
||||
task_id=state.task_id,
|
||||
status=state.status,
|
||||
message=state.message,
|
||||
file_path=state.file_path,
|
||||
created_by=state.created_by,
|
||||
created_at=state.created_at,
|
||||
updated_at=state.updated_at,
|
||||
)
|
||||
for state in states
|
||||
]
|
||||
|
||||
return BackupTaskListResponse(items=items, total=len(items))
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import BroadcastHistory
|
||||
from app.services.broadcast_service import (
|
||||
BroadcastConfig,
|
||||
BroadcastMediaConfig,
|
||||
broadcast_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.broadcasts import (
|
||||
BroadcastCreateRequest,
|
||||
BroadcastListResponse,
|
||||
BroadcastResponse,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
return BroadcastResponse(
|
||||
id=broadcast.id,
|
||||
target_type=broadcast.target_type,
|
||||
message_text=broadcast.message_text,
|
||||
has_media=broadcast.has_media,
|
||||
media_type=broadcast.media_type,
|
||||
media_file_id=broadcast.media_file_id,
|
||||
media_caption=broadcast.media_caption,
|
||||
total_count=broadcast.total_count,
|
||||
sent_count=broadcast.sent_count,
|
||||
failed_count=broadcast.failed_count,
|
||||
status=broadcast.status,
|
||||
admin_id=broadcast.admin_id,
|
||||
admin_name=broadcast.admin_name,
|
||||
created_at=broadcast.created_at,
|
||||
completed_at=broadcast.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_broadcast(
|
||||
payload: BroadcastCreateRequest,
|
||||
token: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> BroadcastResponse:
|
||||
message_text = payload.message_text.strip()
|
||||
if not message_text:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Message text must not be empty")
|
||||
|
||||
media_payload = payload.media
|
||||
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=payload.target,
|
||||
message_text=message_text,
|
||||
has_media=media_payload is not None,
|
||||
media_type=media_payload.type if media_payload else None,
|
||||
media_file_id=media_payload.file_id if media_payload else None,
|
||||
media_caption=media_payload.caption if media_payload else None,
|
||||
total_count=0,
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
status="queued",
|
||||
admin_id=None,
|
||||
admin_name=getattr(token, "name", None) or getattr(token, "created_by", None),
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
media_config = None
|
||||
if media_payload:
|
||||
media_config = BroadcastMediaConfig(
|
||||
type=media_payload.type,
|
||||
file_id=media_payload.file_id,
|
||||
caption=media_payload.caption or message_text,
|
||||
)
|
||||
|
||||
config = BroadcastConfig(
|
||||
target=payload.target,
|
||||
message_text=message_text,
|
||||
selected_buttons=payload.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=getattr(token, "name", None) or getattr(token, "created_by", None),
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, config)
|
||||
await db.refresh(broadcast)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.get("", response_model=BroadcastListResponse)
|
||||
async def list_broadcasts(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> BroadcastListResponse:
|
||||
total = await db.scalar(select(func.count(BroadcastHistory.id))) or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(BroadcastHistory)
|
||||
.order_by(BroadcastHistory.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
broadcasts = result.scalars().all()
|
||||
|
||||
return BroadcastListResponse(
|
||||
items=[_serialize_broadcast(item) for item in broadcasts],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{broadcast_id}/stop", response_model=BroadcastResponse)
|
||||
async def stop_broadcast(
|
||||
broadcast_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> BroadcastResponse:
|
||||
broadcast = await db.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Broadcast not found")
|
||||
|
||||
if broadcast.status not in {"queued", "in_progress", "cancelling"}:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Broadcast is not running")
|
||||
|
||||
is_running = await broadcast_service.request_stop(broadcast_id)
|
||||
|
||||
if is_running:
|
||||
broadcast.status = "cancelling"
|
||||
else:
|
||||
broadcast.status = "cancelled"
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.campaign import (
|
||||
create_campaign,
|
||||
delete_campaign,
|
||||
get_campaign_by_id,
|
||||
get_campaigns_count,
|
||||
get_campaigns_list,
|
||||
update_campaign,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.campaigns import (
|
||||
CampaignCreateRequest,
|
||||
CampaignListResponse,
|
||||
CampaignResponse,
|
||||
CampaignUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_campaign(campaign) -> CampaignResponse:
|
||||
registrations_attr = None
|
||||
if isinstance(getattr(campaign, "__dict__", None), dict):
|
||||
registrations_attr = campaign.__dict__.get("registrations")
|
||||
registrations = registrations_attr or []
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
return CampaignResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
balance_bonus_kopeks=campaign.balance_bonus_kopeks or 0,
|
||||
balance_bonus_rubles=round((campaign.balance_bonus_kopeks or 0) / 100, 2),
|
||||
subscription_duration_days=campaign.subscription_duration_days,
|
||||
subscription_traffic_gb=campaign.subscription_traffic_gb,
|
||||
subscription_device_limit=campaign.subscription_device_limit,
|
||||
subscription_squads=squads,
|
||||
is_active=campaign.is_active,
|
||||
created_by=campaign.created_by,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
registrations_count=len(registrations),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=CampaignResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Создать рекламную кампанию",
|
||||
)
|
||||
async def create_campaign_endpoint(
|
||||
payload: CampaignCreateRequest,
|
||||
token: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> CampaignResponse:
|
||||
created_by = getattr(token, "id", None)
|
||||
|
||||
try:
|
||||
campaign = await create_campaign(
|
||||
db,
|
||||
name=payload.name,
|
||||
start_parameter=payload.start_parameter,
|
||||
bonus_type=payload.bonus_type,
|
||||
created_by=created_by,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_duration_days=payload.subscription_duration_days,
|
||||
subscription_traffic_gb=payload.subscription_traffic_gb,
|
||||
subscription_device_limit=payload.subscription_device_limit,
|
||||
subscription_squads=payload.subscription_squads,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
except IntegrityError as exc: # duplicate start_parameter
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Campaign with this start_parameter already exists",
|
||||
) from exc
|
||||
|
||||
return _serialize_campaign(campaign)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=CampaignListResponse,
|
||||
summary="Список рекламных кампаний",
|
||||
)
|
||||
async def list_campaigns(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
include_inactive: bool = Query(True, description="Включать неактивные кампании"),
|
||||
) -> CampaignListResponse:
|
||||
total = await get_campaigns_count(db, is_active=None if include_inactive else True)
|
||||
campaigns = await get_campaigns_list(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
return CampaignListResponse(
|
||||
items=[_serialize_campaign(campaign) for campaign in campaigns],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{campaign_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Удалить рекламную кампанию",
|
||||
)
|
||||
async def delete_campaign_endpoint(
|
||||
campaign_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Campaign not found")
|
||||
|
||||
await delete_campaign(db, campaign)
|
||||
return None
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{campaign_id}",
|
||||
response_model=CampaignResponse,
|
||||
summary="Обновить рекламную кампанию",
|
||||
)
|
||||
async def update_campaign_endpoint(
|
||||
campaign_id: int,
|
||||
payload: CampaignUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> CampaignResponse:
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Campaign not found")
|
||||
|
||||
update_fields = payload.dict(exclude_unset=True)
|
||||
if not update_fields:
|
||||
return _serialize_campaign(campaign)
|
||||
|
||||
try:
|
||||
campaign = await update_campaign(db, campaign, **update_fields)
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
if "start_parameter" in str(exc.orig):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Campaign with this start_parameter already exists",
|
||||
) from exc
|
||||
raise
|
||||
|
||||
return _serialize_campaign(campaign)
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.config import (
|
||||
SettingCategoryRef,
|
||||
SettingCategorySummary,
|
||||
SettingChoice,
|
||||
SettingDefinition,
|
||||
SettingUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _coerce_value(key: str, value: Any) -> Any:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
|
||||
if value is None:
|
||||
if definition.is_optional:
|
||||
return None
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required")
|
||||
|
||||
python_type = definition.python_type
|
||||
|
||||
try:
|
||||
if python_type is bool:
|
||||
if isinstance(value, bool):
|
||||
normalized = value
|
||||
elif isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on", "да"}:
|
||||
normalized = True
|
||||
elif lowered in {"false", "0", "no", "off", "нет"}:
|
||||
normalized = False
|
||||
else:
|
||||
raise ValueError("invalid bool")
|
||||
else:
|
||||
raise ValueError("invalid bool")
|
||||
|
||||
elif python_type is int:
|
||||
normalized = int(value)
|
||||
elif python_type is float:
|
||||
normalized = float(value)
|
||||
else:
|
||||
normalized = str(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None
|
||||
|
||||
choices = bot_configuration_service.get_choice_options(key)
|
||||
if choices:
|
||||
allowed_values = {option.value for option in choices}
|
||||
if normalized not in allowed_values:
|
||||
readable = ", ".join(bot_configuration_service.format_value(opt.value) for opt in choices)
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Value must be one of: {readable}",
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize_definition(definition, include_choices: bool = True) -> SettingDefinition:
|
||||
current = bot_configuration_service.get_current_value(definition.key)
|
||||
original = bot_configuration_service.get_original_value(definition.key)
|
||||
has_override = bot_configuration_service.has_override(definition.key)
|
||||
|
||||
choices: list[SettingChoice] = []
|
||||
if include_choices:
|
||||
choices = [
|
||||
SettingChoice(
|
||||
value=option.value,
|
||||
label=option.label,
|
||||
description=option.description,
|
||||
)
|
||||
for option in bot_configuration_service.get_choice_options(definition.key)
|
||||
]
|
||||
|
||||
return SettingDefinition(
|
||||
key=definition.key,
|
||||
name=definition.display_name,
|
||||
category=SettingCategoryRef(
|
||||
key=definition.category_key,
|
||||
label=definition.category_label,
|
||||
),
|
||||
type=definition.type_label,
|
||||
is_optional=definition.is_optional,
|
||||
current=current,
|
||||
original=original,
|
||||
has_override=has_override,
|
||||
choices=choices,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[SettingCategorySummary])
|
||||
async def list_categories(
|
||||
_: object = Security(require_api_token),
|
||||
) -> list[SettingCategorySummary]:
|
||||
categories = bot_configuration_service.get_categories()
|
||||
return [
|
||||
SettingCategorySummary(key=key, label=label, items=count)
|
||||
for key, label, count in categories
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=list[SettingDefinition])
|
||||
async def list_settings(
|
||||
_: object = Security(require_api_token),
|
||||
category: Optional[str] = Query(default=None, alias="category_key"),
|
||||
) -> list[SettingDefinition]:
|
||||
items: list[SettingDefinition] = []
|
||||
if category:
|
||||
definitions = bot_configuration_service.get_settings_for_category(category)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
return items
|
||||
|
||||
for category_key, _, _ in bot_configuration_service.get_categories():
|
||||
definitions = bot_configuration_service.get_settings_for_category(category_key)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingDefinition)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
_: object = Security(require_api_token),
|
||||
) -> SettingDefinition:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error: # pragma: no cover - защита от некорректного ключа
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=SettingDefinition)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
payload: SettingUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SettingDefinition:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
value = _coerce_value(key, payload.value)
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
await db.commit()
|
||||
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=SettingDefinition)
|
||||
async def reset_setting(
|
||||
key: str,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SettingDefinition:
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
await db.commit()
|
||||
return _serialize_definition(definition)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Security
|
||||
|
||||
from app.config import settings
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import require_api_token
|
||||
from ..schemas.health import HealthCheckResponse, HealthFeatureFlags
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", tags=["health"], response_model=HealthCheckResponse)
|
||||
async def health_check(_: object = Security(require_api_token)) -> HealthCheckResponse:
|
||||
return HealthCheckResponse(
|
||||
status="ok",
|
||||
api_version=settings.WEB_API_VERSION,
|
||||
bot_version=version_service.current_version,
|
||||
features=HealthFeatureFlags(
|
||||
monitoring=settings.MONITORING_INTERVAL > 0,
|
||||
maintenance=True,
|
||||
reporting=True,
|
||||
webhooks=bool(settings.WEBHOOK_URL),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import Subscription, Transaction, User
|
||||
from app.services.remnawave_service import (
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.subscription_utils import get_happ_cryptolink_redirect_link
|
||||
from app.utils.telegram_webapp import (
|
||||
TelegramWebAppAuthError,
|
||||
parse_webapp_init_data,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session
|
||||
from ..schemas.miniapp import (
|
||||
MiniAppConnectedServer,
|
||||
MiniAppDevice,
|
||||
MiniAppPromoGroup,
|
||||
MiniAppSubscriptionRequest,
|
||||
MiniAppSubscriptionResponse,
|
||||
MiniAppSubscriptionUser,
|
||||
MiniAppTransaction,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _format_gb(value: Optional[float]) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _format_gb_label(value: float) -> str:
|
||||
absolute = abs(value)
|
||||
if absolute >= 100:
|
||||
return f"{value:.0f} GB"
|
||||
if absolute >= 10:
|
||||
return f"{value:.1f} GB"
|
||||
return f"{value:.2f} GB"
|
||||
|
||||
|
||||
def _format_limit_label(limit: Optional[int]) -> str:
|
||||
if not limit:
|
||||
return "Unlimited"
|
||||
return f"{limit} GB"
|
||||
|
||||
|
||||
def _bytes_to_gb(bytes_value: Optional[int]) -> float:
|
||||
if not bytes_value:
|
||||
return 0.0
|
||||
return round(bytes_value / (1024 ** 3), 2)
|
||||
|
||||
|
||||
def _status_label(status: str) -> str:
|
||||
mapping = {
|
||||
"active": "Active",
|
||||
"trial": "Trial",
|
||||
"expired": "Expired",
|
||||
"disabled": "Disabled",
|
||||
}
|
||||
return mapping.get(status, status.title())
|
||||
|
||||
|
||||
def _parse_datetime_string(value: Optional[str]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
cleaned = value.strip()
|
||||
if cleaned.endswith("Z"):
|
||||
cleaned = f"{cleaned[:-1]}+00:00"
|
||||
# Normalize duplicated timezone suffixes like +00:00+00:00
|
||||
if "+00:00+00:00" in cleaned:
|
||||
cleaned = cleaned.replace("+00:00+00:00", "+00:00")
|
||||
|
||||
datetime.fromisoformat(cleaned)
|
||||
return cleaned
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return value
|
||||
|
||||
|
||||
async def _resolve_connected_servers(
|
||||
db: AsyncSession,
|
||||
squad_uuids: List[str],
|
||||
) -> List[MiniAppConnectedServer]:
|
||||
if not squad_uuids:
|
||||
return []
|
||||
|
||||
resolved: Dict[str, str] = {}
|
||||
missing: List[str] = []
|
||||
|
||||
for squad_uuid in squad_uuids:
|
||||
if squad_uuid in resolved:
|
||||
continue
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server and server.display_name:
|
||||
resolved[squad_uuid] = server.display_name
|
||||
else:
|
||||
missing.append(squad_uuid)
|
||||
|
||||
if missing:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
if service.is_configured:
|
||||
squads = await service.get_all_squads()
|
||||
for squad in squads:
|
||||
uuid = squad.get("uuid")
|
||||
name = squad.get("name")
|
||||
if uuid in missing and name:
|
||||
resolved[uuid] = name
|
||||
except RemnaWaveConfigurationError:
|
||||
logger.debug("RemnaWave is not configured; skipping server name enrichment")
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to resolve server names from RemnaWave: %s", error)
|
||||
|
||||
connected_servers: List[MiniAppConnectedServer] = []
|
||||
for squad_uuid in squad_uuids:
|
||||
name = resolved.get(squad_uuid, squad_uuid)
|
||||
connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name))
|
||||
|
||||
return connected_servers
|
||||
|
||||
|
||||
async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]:
|
||||
remnawave_uuid = getattr(user, "remnawave_uuid", None)
|
||||
if not remnawave_uuid:
|
||||
return 0, []
|
||||
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to initialise RemnaWave service: %s", error)
|
||||
return 0, []
|
||||
|
||||
if not service.is_configured:
|
||||
return 0, []
|
||||
|
||||
try:
|
||||
async with service.get_api_client() as api:
|
||||
response = await api.get_user_devices(remnawave_uuid)
|
||||
except RemnaWaveConfigurationError:
|
||||
logger.debug("RemnaWave configuration missing while loading devices")
|
||||
return 0, []
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to load devices from RemnaWave: %s", error)
|
||||
return 0, []
|
||||
|
||||
total_devices = int(response.get("total") or 0)
|
||||
devices_payload = response.get("devices") or []
|
||||
|
||||
devices: List[MiniAppDevice] = []
|
||||
for device in devices_payload:
|
||||
platform = device.get("platform") or device.get("platformType")
|
||||
model = device.get("deviceModel") or device.get("model") or device.get("name")
|
||||
app_version = device.get("appVersion") or device.get("version")
|
||||
last_seen_raw = (
|
||||
device.get("updatedAt")
|
||||
or device.get("lastSeen")
|
||||
or device.get("lastActiveAt")
|
||||
or device.get("createdAt")
|
||||
)
|
||||
last_ip = device.get("ip") or device.get("ipAddress")
|
||||
|
||||
devices.append(
|
||||
MiniAppDevice(
|
||||
platform=platform,
|
||||
device_model=model,
|
||||
app_version=app_version,
|
||||
last_seen=_parse_datetime_string(last_seen_raw),
|
||||
last_ip=last_ip,
|
||||
)
|
||||
)
|
||||
|
||||
if total_devices == 0:
|
||||
total_devices = len(devices)
|
||||
|
||||
return total_devices, devices
|
||||
|
||||
|
||||
def _resolve_display_name(user_data: Dict[str, Any]) -> str:
|
||||
username = user_data.get("username")
|
||||
if username:
|
||||
return username
|
||||
|
||||
first = user_data.get("first_name")
|
||||
last = user_data.get("last_name")
|
||||
parts = [part for part in [first, last] if part]
|
||||
if parts:
|
||||
return " ".join(parts)
|
||||
|
||||
telegram_id = user_data.get("telegram_id")
|
||||
return f"User {telegram_id}" if telegram_id else "User"
|
||||
|
||||
|
||||
def _is_remnawave_configured() -> bool:
|
||||
params = settings.get_remnawave_auth_params()
|
||||
return bool(params.get("base_url") and params.get("api_key"))
|
||||
|
||||
|
||||
def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction:
|
||||
return MiniAppTransaction(
|
||||
id=transaction.id,
|
||||
type=transaction.type,
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
amount_rubles=round(transaction.amount_kopeks / 100, 2),
|
||||
description=transaction.description,
|
||||
payment_method=transaction.payment_method,
|
||||
external_id=transaction.external_id,
|
||||
is_completed=transaction.is_completed,
|
||||
created_at=transaction.created_at,
|
||||
completed_at=transaction.completed_at,
|
||||
)
|
||||
|
||||
|
||||
async def _load_subscription_links(
|
||||
subscription: Subscription,
|
||||
) -> Dict[str, Any]:
|
||||
if not subscription.remnawave_short_uuid or not _is_remnawave_configured():
|
||||
return {}
|
||||
|
||||
try:
|
||||
service = SubscriptionService()
|
||||
info = await service.get_subscription_info(subscription.remnawave_short_uuid)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to load subscription info from RemnaWave: %s", error)
|
||||
return {}
|
||||
|
||||
if not info:
|
||||
return {}
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"links": list(info.links or []),
|
||||
"ss_conf_links": dict(info.ss_conf_links or {}),
|
||||
"subscription_url": info.subscription_url,
|
||||
"happ": info.happ,
|
||||
"happ_link": getattr(info, "happ_link", None),
|
||||
"happ_crypto_link": getattr(info, "happ_crypto_link", None),
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/subscription", response_model=MiniAppSubscriptionResponse)
|
||||
async def get_subscription_details(
|
||||
payload: MiniAppSubscriptionRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionResponse:
|
||||
try:
|
||||
webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN)
|
||||
except TelegramWebAppAuthError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
telegram_user = webapp_data.get("user")
|
||||
if not isinstance(telegram_user, dict) or "id" not in telegram_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Telegram user payload",
|
||||
)
|
||||
|
||||
try:
|
||||
telegram_id = int(telegram_user["id"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid Telegram user identifier",
|
||||
) from None
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
|
||||
if not user or not user.subscription:
|
||||
detail: Union[str, Dict[str, str]] = "Subscription not found"
|
||||
if purchase_url:
|
||||
detail = {
|
||||
"message": "Subscription not found",
|
||||
"purchase_url": purchase_url,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
subscription = user.subscription
|
||||
traffic_used = _format_gb(subscription.traffic_used_gb)
|
||||
traffic_limit = subscription.traffic_limit_gb or 0
|
||||
lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0))
|
||||
|
||||
status_actual = subscription.actual_status
|
||||
links_payload = await _load_subscription_links(subscription)
|
||||
|
||||
subscription_url = links_payload.get("subscription_url") or subscription.subscription_url
|
||||
subscription_crypto_link = (
|
||||
links_payload.get("happ_crypto_link")
|
||||
or subscription.subscription_crypto_link
|
||||
)
|
||||
|
||||
happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link)
|
||||
|
||||
connected_squads: List[str] = list(subscription.connected_squads or [])
|
||||
connected_servers = await _resolve_connected_servers(db, connected_squads)
|
||||
devices_count, devices = await _load_devices_info(user)
|
||||
links: List[str] = links_payload.get("links") or connected_squads
|
||||
ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {}
|
||||
|
||||
transactions_query = (
|
||||
select(Transaction)
|
||||
.where(Transaction.user_id == user.id)
|
||||
.order_by(Transaction.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
transactions_result = await db.execute(transactions_query)
|
||||
transactions = list(transactions_result.scalars().all())
|
||||
|
||||
balance_currency = getattr(user, "balance_currency", None)
|
||||
if isinstance(balance_currency, str):
|
||||
balance_currency = balance_currency.upper()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
response_user = MiniAppSubscriptionUser(
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
display_name=_resolve_display_name(
|
||||
{
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"telegram_id": user.telegram_id,
|
||||
}
|
||||
),
|
||||
language=user.language,
|
||||
status=user.status,
|
||||
subscription_status=subscription.status,
|
||||
subscription_actual_status=status_actual,
|
||||
status_label=_status_label(status_actual),
|
||||
expires_at=subscription.end_date,
|
||||
device_limit=subscription.device_limit,
|
||||
traffic_used_gb=round(traffic_used, 2),
|
||||
traffic_used_label=_format_gb_label(traffic_used),
|
||||
traffic_limit_gb=traffic_limit,
|
||||
traffic_limit_label=_format_limit_label(traffic_limit),
|
||||
lifetime_used_traffic_gb=lifetime_used,
|
||||
has_active_subscription=status_actual in {"active", "trial"},
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionResponse(
|
||||
subscription_id=subscription.id,
|
||||
remnawave_short_uuid=subscription.remnawave_short_uuid,
|
||||
user=response_user,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
subscription_purchase_url=purchase_url or None,
|
||||
links=links,
|
||||
ss_conf_links=ss_conf_links,
|
||||
connected_squads=connected_squads,
|
||||
connected_servers=connected_servers,
|
||||
connected_devices_count=devices_count,
|
||||
connected_devices=devices,
|
||||
happ=links_payload.get("happ"),
|
||||
happ_link=links_payload.get("happ_link"),
|
||||
happ_crypto_link=links_payload.get("happ_crypto_link"),
|
||||
happ_cryptolink_redirect_link=happ_redirect_link,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_rubles=round(user.balance_rubles, 2),
|
||||
balance_currency=balance_currency,
|
||||
transactions=[_serialize_transaction(tx) for tx in transactions],
|
||||
promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name)
|
||||
if promo_group
|
||||
else None,
|
||||
subscription_type="trial" if subscription.is_trial else "paid",
|
||||
autopay_enabled=bool(subscription.autopay_enabled),
|
||||
branding=settings.get_miniapp_branding(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promo_group import (
|
||||
count_promo_group_members,
|
||||
count_promo_groups,
|
||||
create_promo_group,
|
||||
delete_promo_group,
|
||||
get_promo_group_by_id,
|
||||
get_promo_groups_with_counts,
|
||||
update_promo_group,
|
||||
)
|
||||
from app.database.models import PromoGroup
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.promo_groups import (
|
||||
PromoGroupCreateRequest,
|
||||
PromoGroupListResponse,
|
||||
PromoGroupResponse,
|
||||
PromoGroupUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _normalize_period_discounts(group: PromoGroup) -> dict[int, int]:
|
||||
raw = group.period_discounts or {}
|
||||
normalized: dict[int, int] = {}
|
||||
if isinstance(raw, dict):
|
||||
for key, value in raw.items():
|
||||
try:
|
||||
normalized[int(key)] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
|
||||
return PromoGroupResponse(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
server_discount_percent=group.server_discount_percent,
|
||||
traffic_discount_percent=group.traffic_discount_percent,
|
||||
device_discount_percent=group.device_discount_percent,
|
||||
period_discounts=_normalize_period_discounts(group),
|
||||
auto_assign_total_spent_kopeks=group.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=group.apply_discounts_to_addons,
|
||||
is_default=group.is_default,
|
||||
members_count=members_count,
|
||||
created_at=group.created_at,
|
||||
updated_at=group.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoGroupListResponse)
|
||||
async def list_promo_groups(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PromoGroupListResponse:
|
||||
total = await count_promo_groups(db)
|
||||
groups_with_counts = await get_promo_groups_with_counts(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return PromoGroupListResponse(
|
||||
items=[_serialize(group, members_count=count) for group, count in groups_with_counts],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=PromoGroupResponse)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoGroupResponse:
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
|
||||
|
||||
members_count = await count_promo_group_members(db, group_id)
|
||||
return _serialize(group, members_count=members_count)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoGroupResponse:
|
||||
try:
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
name=payload.name,
|
||||
server_discount_percent=payload.server_discount_percent,
|
||||
traffic_discount_percent=payload.traffic_discount_percent,
|
||||
device_discount_percent=payload.device_discount_percent,
|
||||
period_discounts=payload.period_discounts,
|
||||
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=payload.apply_discounts_to_addons,
|
||||
is_default=payload.is_default,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Promo group with this name already exists",
|
||||
) from exc
|
||||
return _serialize(group, members_count=0)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=PromoGroupResponse)
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoGroupResponse:
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
|
||||
|
||||
try:
|
||||
group = await update_promo_group(
|
||||
db,
|
||||
group,
|
||||
name=payload.name,
|
||||
server_discount_percent=payload.server_discount_percent,
|
||||
traffic_discount_percent=payload.traffic_discount_percent,
|
||||
device_discount_percent=payload.device_discount_percent,
|
||||
period_discounts=payload.period_discounts,
|
||||
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=payload.apply_discounts_to_addons,
|
||||
is_default=payload.is_default,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Promo group with this name already exists",
|
||||
) from exc
|
||||
members_count = await count_promo_group_members(db, group_id)
|
||||
return _serialize(group, members_count=members_count)
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_promo_group_endpoint(
|
||||
group_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
|
||||
|
||||
success = await delete_promo_group(db, group)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Cannot delete default promo group")
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promocode import (
|
||||
create_promocode,
|
||||
delete_promocode,
|
||||
get_promocode_by_code,
|
||||
get_promocode_statistics,
|
||||
get_promocodes_count,
|
||||
get_promocodes_list,
|
||||
update_promocode,
|
||||
)
|
||||
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.promocodes import (
|
||||
PromoCodeCreateRequest,
|
||||
PromoCodeDetailResponse,
|
||||
PromoCodeListResponse,
|
||||
PromoCodeRecentUse,
|
||||
PromoCodeResponse,
|
||||
PromoCodeUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _normalize_datetime(value: Optional[datetime]) -> Optional[datetime]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if value.tzinfo is not None and value.utcoffset() is not None:
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if value.tzinfo is not None:
|
||||
return value.replace(tzinfo=None)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
|
||||
promo_type = PromoCodeType(promocode.type)
|
||||
return PromoCodeResponse(
|
||||
id=promocode.id,
|
||||
code=promocode.code,
|
||||
type=promo_type,
|
||||
balance_bonus_kopeks=promocode.balance_bonus_kopeks,
|
||||
balance_bonus_rubles=round(promocode.balance_bonus_kopeks / 100, 2),
|
||||
subscription_days=promocode.subscription_days,
|
||||
max_uses=promocode.max_uses,
|
||||
current_uses=promocode.current_uses,
|
||||
uses_left=promocode.uses_left,
|
||||
is_active=promocode.is_active,
|
||||
is_valid=promocode.is_valid,
|
||||
valid_from=promocode.valid_from,
|
||||
valid_until=promocode.valid_until,
|
||||
created_by=promocode.created_by,
|
||||
created_at=promocode.created_at,
|
||||
updated_at=promocode.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_recent_use(use: PromoCodeUse) -> PromoCodeRecentUse:
|
||||
return PromoCodeRecentUse(
|
||||
id=use.id,
|
||||
user_id=use.user_id,
|
||||
user_username=getattr(use, "user_username", None),
|
||||
user_full_name=getattr(use, "user_full_name", None),
|
||||
user_telegram_id=getattr(use, "user_telegram_id", None),
|
||||
used_at=use.used_at,
|
||||
)
|
||||
|
||||
|
||||
def _validate_create_payload(payload: PromoCodeCreateRequest) -> None:
|
||||
code = payload.code.strip()
|
||||
if not code:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Code must not be empty")
|
||||
|
||||
normalized_valid_from = _normalize_datetime(payload.valid_from)
|
||||
normalized_valid_until = _normalize_datetime(payload.valid_until)
|
||||
|
||||
if payload.type == PromoCodeType.BALANCE and payload.balance_bonus_kopeks <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Balance bonus must be positive for balance promo codes")
|
||||
|
||||
if payload.type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION} and payload.subscription_days <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription days must be positive for this promo code type")
|
||||
|
||||
if normalized_valid_from and normalized_valid_until and normalized_valid_from > normalized_valid_until:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_from cannot be greater than valid_until")
|
||||
|
||||
|
||||
def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCode) -> None:
|
||||
if payload.code is not None and not payload.code.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Code must not be empty")
|
||||
|
||||
if payload.type is not None:
|
||||
new_type = payload.type
|
||||
else:
|
||||
new_type = PromoCodeType(promocode.type)
|
||||
|
||||
balance_bonus = (
|
||||
payload.balance_bonus_kopeks
|
||||
if payload.balance_bonus_kopeks is not None
|
||||
else promocode.balance_bonus_kopeks
|
||||
)
|
||||
subscription_days = (
|
||||
payload.subscription_days
|
||||
if payload.subscription_days is not None
|
||||
else promocode.subscription_days
|
||||
)
|
||||
|
||||
if new_type == PromoCodeType.BALANCE and balance_bonus <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Balance bonus must be positive for balance promo codes")
|
||||
|
||||
if new_type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION} and subscription_days <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription days must be positive for this promo code type")
|
||||
|
||||
valid_from = (
|
||||
_normalize_datetime(payload.valid_from)
|
||||
if payload.valid_from is not None
|
||||
else promocode.valid_from
|
||||
)
|
||||
valid_until = (
|
||||
_normalize_datetime(payload.valid_until)
|
||||
if payload.valid_until is not None
|
||||
else promocode.valid_until
|
||||
)
|
||||
|
||||
if valid_from and valid_until and valid_from > valid_until:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_from cannot be greater than valid_until")
|
||||
|
||||
if payload.max_uses is not None and payload.max_uses != 0 and payload.max_uses < promocode.current_uses:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "max_uses cannot be less than current uses")
|
||||
|
||||
|
||||
@router.get("", response_model=PromoCodeListResponse)
|
||||
async def list_promocodes(
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
is_active: Optional[bool] = Query(default=None),
|
||||
) -> PromoCodeListResponse:
|
||||
total = await get_promocodes_count(db, is_active=is_active) or 0
|
||||
promocodes = await get_promocodes_list(db, offset=offset, limit=limit, is_active=is_active)
|
||||
|
||||
return PromoCodeListResponse(
|
||||
items=[_serialize_promocode(promocode) for promocode in promocodes],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{promocode_id}", response_model=PromoCodeDetailResponse)
|
||||
async def get_promocode(
|
||||
promocode_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoCodeDetailResponse:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
|
||||
|
||||
stats = await get_promocode_statistics(db, promocode_id)
|
||||
base = _serialize_promocode(promocode)
|
||||
recent_uses = [
|
||||
_serialize_recent_use(use)
|
||||
for use in stats.get("recent_uses", [])
|
||||
]
|
||||
|
||||
return PromoCodeDetailResponse(
|
||||
**base.dict(),
|
||||
total_uses=stats.get("total_uses", 0),
|
||||
today_uses=stats.get("today_uses", 0),
|
||||
recent_uses=recent_uses,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promocode_endpoint(
|
||||
payload: PromoCodeCreateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoCodeResponse:
|
||||
_validate_create_payload(payload)
|
||||
|
||||
normalized_code = payload.code.strip().upper()
|
||||
normalized_valid_from = _normalize_datetime(payload.valid_from)
|
||||
normalized_valid_until = _normalize_datetime(payload.valid_until)
|
||||
|
||||
existing = await get_promocode_by_code(db, normalized_code)
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo code with this code already exists")
|
||||
|
||||
creator_id = (
|
||||
payload.created_by
|
||||
if payload.created_by is not None and payload.created_by > 0
|
||||
else None
|
||||
)
|
||||
|
||||
promocode = await create_promocode(
|
||||
db,
|
||||
code=normalized_code,
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=creator_id,
|
||||
)
|
||||
|
||||
update_fields = {}
|
||||
if normalized_valid_from is not None:
|
||||
update_fields["valid_from"] = normalized_valid_from
|
||||
if payload.is_active is not None and payload.is_active != promocode.is_active:
|
||||
update_fields["is_active"] = payload.is_active
|
||||
if normalized_valid_until is not None:
|
||||
update_fields["valid_until"] = normalized_valid_until
|
||||
|
||||
if update_fields:
|
||||
promocode = await update_promocode(db, promocode, **update_fields)
|
||||
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
|
||||
@router.patch("/{promocode_id}", response_model=PromoCodeResponse)
|
||||
async def update_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
payload: PromoCodeUpdateRequest,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoCodeResponse:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
|
||||
|
||||
_validate_update_payload(payload, promocode)
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
|
||||
if payload.code is not None:
|
||||
normalized_code = payload.code.strip().upper()
|
||||
if normalized_code != promocode.code:
|
||||
existing = await get_promocode_by_code(db, normalized_code)
|
||||
if existing and existing.id != promocode_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo code with this code already exists")
|
||||
updates["code"] = normalized_code
|
||||
|
||||
if payload.type is not None:
|
||||
updates["type"] = payload.type.value
|
||||
|
||||
if payload.balance_bonus_kopeks is not None:
|
||||
updates["balance_bonus_kopeks"] = payload.balance_bonus_kopeks
|
||||
|
||||
if payload.subscription_days is not None:
|
||||
updates["subscription_days"] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates["max_uses"] = payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates["valid_from"] = _normalize_datetime(payload.valid_from)
|
||||
|
||||
if payload.valid_until is not None:
|
||||
updates["valid_until"] = _normalize_datetime(payload.valid_until)
|
||||
|
||||
if payload.is_active is not None:
|
||||
updates["is_active"] = payload.is_active
|
||||
|
||||
if not updates:
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
promocode = await update_promocode(db, promocode, **updates)
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{promocode_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
)
|
||||
async def delete_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
_: Any = Depends(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
|
||||
|
||||
success = await delete_promocode(db, promocode)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Failed to delete promo code")
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
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 ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.remnawave import (
|
||||
RemnaWaveConnectionStatus,
|
||||
RemnaWaveGenericSyncResponse,
|
||||
RemnaWaveInboundsResponse,
|
||||
RemnaWaveNode,
|
||||
RemnaWaveNodeActionRequest,
|
||||
RemnaWaveNodeActionResponse,
|
||||
RemnaWaveNodeListResponse,
|
||||
RemnaWaveNodeStatisticsResponse,
|
||||
RemnaWaveNodeUsageResponse,
|
||||
RemnaWaveOperationResponse,
|
||||
RemnaWaveSquad,
|
||||
RemnaWaveSquadActionRequest,
|
||||
RemnaWaveSquadCreateRequest,
|
||||
RemnaWaveSquadListResponse,
|
||||
RemnaWaveSquadUpdateRequest,
|
||||
RemnaWaveStatusResponse,
|
||||
RemnaWaveSystemStatsResponse,
|
||||
RemnaWaveSyncFromPanelRequest,
|
||||
RemnaWaveUserTrafficResponse,
|
||||
)
|
||||
|
||||
try: # pragma: no cover - импорт может не работать без optional-зависимостей
|
||||
from app.services.remnawave_service import ( # type: ignore
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
)
|
||||
except Exception: # pragma: no cover - при ошибке импорта скрываем функционал
|
||||
RemnaWaveConfigurationError = None # type: ignore[assignment]
|
||||
RemnaWaveService = None # type: ignore[assignment]
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - только для типов в IDE
|
||||
from app.services.remnawave_service import RemnaWaveService as RemnaWaveServiceType
|
||||
else:
|
||||
RemnaWaveServiceType = Any
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service() -> "RemnaWaveServiceType":
|
||||
if RemnaWaveService is None: # pragma: no cover - зависимость не доступна
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="RemnaWave сервис недоступен",
|
||||
)
|
||||
|
||||
return RemnaWaveService()
|
||||
|
||||
|
||||
def _ensure_service_configured(service: "RemnaWaveServiceType") -> None:
|
||||
if RemnaWaveService is None: # pragma: no cover - зависимость не доступна
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="RemnaWave сервис недоступен",
|
||||
)
|
||||
|
||||
if not service.is_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=service.configuration_error or "RemnaWave API не настроен",
|
||||
)
|
||||
|
||||
|
||||
def _serialize_node(node_data: Dict[str, Any]) -> RemnaWaveNode:
|
||||
return RemnaWaveNode(
|
||||
uuid=node_data.get("uuid", ""),
|
||||
name=node_data.get("name", ""),
|
||||
address=node_data.get("address", ""),
|
||||
country_code=node_data.get("country_code"),
|
||||
is_connected=bool(node_data.get("is_connected")),
|
||||
is_disabled=bool(node_data.get("is_disabled")),
|
||||
is_node_online=bool(node_data.get("is_node_online")),
|
||||
is_xray_running=bool(node_data.get("is_xray_running")),
|
||||
users_online=node_data.get("users_online"),
|
||||
traffic_used_bytes=node_data.get("traffic_used_bytes"),
|
||||
traffic_limit_bytes=node_data.get("traffic_limit_bytes"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_last_updated(value: Any) -> Optional[datetime]:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/status", response_model=RemnaWaveStatusResponse)
|
||||
async def get_remnawave_status(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveStatusResponse:
|
||||
service = _get_service()
|
||||
|
||||
connection_info: Optional[RemnaWaveConnectionStatus] = None
|
||||
connection_result = await service.test_api_connection()
|
||||
|
||||
if connection_result:
|
||||
connection_info = RemnaWaveConnectionStatus(**connection_result)
|
||||
|
||||
return RemnaWaveStatusResponse(
|
||||
is_configured=service.is_configured,
|
||||
configuration_error=service.configuration_error,
|
||||
connection=connection_info,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system", response_model=RemnaWaveSystemStatsResponse)
|
||||
async def get_system_statistics(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveSystemStatsResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.get_system_statistics()
|
||||
if not stats or "system" not in stats:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, "Не удалось получить статистику RemnaWave")
|
||||
|
||||
stats["last_updated"] = _parse_last_updated(stats.get("last_updated"))
|
||||
return RemnaWaveSystemStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.get("/nodes", response_model=RemnaWaveNodeListResponse)
|
||||
async def list_nodes(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNodeListResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
nodes = await service.get_all_nodes()
|
||||
serialized = [_serialize_node(node) for node in nodes]
|
||||
return RemnaWaveNodeListResponse(items=serialized, total=len(serialized))
|
||||
|
||||
|
||||
@router.get("/nodes/realtime", response_model=List[Dict[str, Any]])
|
||||
async def get_nodes_realtime_usage(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> List[Dict[str, Any]]:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
return await service.get_nodes_realtime_usage()
|
||||
|
||||
|
||||
@router.get("/nodes/{node_uuid}", response_model=RemnaWaveNode)
|
||||
async def get_node_details(
|
||||
node_uuid: str,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNode:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
node = await service.get_node_details(node_uuid)
|
||||
if not node:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Нода не найдена")
|
||||
return _serialize_node(node)
|
||||
|
||||
|
||||
@router.get("/nodes/{node_uuid}/statistics", response_model=RemnaWaveNodeStatisticsResponse)
|
||||
async def get_node_statistics(
|
||||
node_uuid: str,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNodeStatisticsResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.get_node_statistics(node_uuid)
|
||||
if not stats or not stats.get("node"):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Не удалось получить информацию по ноде")
|
||||
|
||||
node_data = _serialize_node(stats["node"])
|
||||
usage_history = stats.get("usage_history") or []
|
||||
realtime = stats.get("realtime")
|
||||
last_updated = _parse_last_updated(stats.get("last_updated"))
|
||||
|
||||
return RemnaWaveNodeStatisticsResponse(
|
||||
node=node_data,
|
||||
realtime=realtime,
|
||||
usage_history=usage_history,
|
||||
last_updated=last_updated,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nodes/{node_uuid}/usage", response_model=RemnaWaveNodeUsageResponse)
|
||||
async def get_node_usage_range(
|
||||
node_uuid: str,
|
||||
start: Optional[datetime] = Query(default=None),
|
||||
end: Optional[datetime] = Query(default=None),
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNodeUsageResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
end_dt = end or datetime.utcnow()
|
||||
start_dt = start or (end_dt - timedelta(days=7))
|
||||
|
||||
if start_dt >= end_dt:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Некорректный диапазон дат")
|
||||
|
||||
usage = await service.get_node_user_usage_by_range(node_uuid, start_dt, end_dt)
|
||||
return RemnaWaveNodeUsageResponse(items=usage or [])
|
||||
|
||||
|
||||
@router.post("/nodes/{node_uuid}/actions", response_model=RemnaWaveNodeActionResponse)
|
||||
async def manage_node(
|
||||
node_uuid: str,
|
||||
payload: RemnaWaveNodeActionRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNodeActionResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
success = await service.manage_node(node_uuid, payload.action)
|
||||
detail = None
|
||||
if success:
|
||||
if payload.action == "enable":
|
||||
detail = "Нода включена"
|
||||
elif payload.action == "disable":
|
||||
detail = "Нода отключена"
|
||||
elif payload.action == "restart":
|
||||
detail = "Команда перезапуска отправлена"
|
||||
else:
|
||||
detail = "Не удалось выполнить действие"
|
||||
|
||||
return RemnaWaveNodeActionResponse(success=success, detail=detail)
|
||||
|
||||
|
||||
@router.post("/nodes/restart", response_model=RemnaWaveNodeActionResponse)
|
||||
async def restart_all_nodes(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveNodeActionResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
success = await service.restart_all_nodes()
|
||||
detail = "Команда перезапуска отправлена" if success else "Не удалось перезапустить ноды"
|
||||
return RemnaWaveNodeActionResponse(success=success, detail=detail)
|
||||
|
||||
|
||||
@router.get("/squads", response_model=RemnaWaveSquadListResponse)
|
||||
async def list_squads(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveSquadListResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
squads = await service.get_all_squads()
|
||||
serialized = [RemnaWaveSquad(**squad) for squad in squads]
|
||||
return RemnaWaveSquadListResponse(items=serialized, total=len(serialized))
|
||||
|
||||
|
||||
@router.get("/squads/{squad_uuid}", response_model=RemnaWaveSquad)
|
||||
async def get_squad_details(
|
||||
squad_uuid: str,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveSquad:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
squad = await service.get_squad_details(squad_uuid)
|
||||
if not squad:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден")
|
||||
return RemnaWaveSquad(**squad)
|
||||
|
||||
|
||||
@router.post("/squads", response_model=RemnaWaveOperationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_squad(
|
||||
payload: RemnaWaveSquadCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveOperationResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
success = await service.create_squad(payload.name, payload.inbound_uuids)
|
||||
detail = "Сквад успешно создан" if success else "Не удалось создать сквад"
|
||||
return RemnaWaveOperationResponse(success=success, detail=detail)
|
||||
|
||||
|
||||
@router.patch("/squads/{squad_uuid}", response_model=RemnaWaveOperationResponse)
|
||||
async def update_squad(
|
||||
squad_uuid: str,
|
||||
payload: RemnaWaveSquadUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveOperationResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
success = False
|
||||
detail = "Необходимо указать новые данные"
|
||||
|
||||
if payload.name is not None or payload.inbound_uuids is not None:
|
||||
success = await service.update_squad(
|
||||
squad_uuid,
|
||||
name=payload.name,
|
||||
inbounds=payload.inbound_uuids,
|
||||
)
|
||||
detail = "Сквад обновлен" if success else "Не удалось обновить сквад"
|
||||
|
||||
return RemnaWaveOperationResponse(success=success, detail=detail)
|
||||
|
||||
|
||||
@router.post("/squads/{squad_uuid}/actions", response_model=RemnaWaveOperationResponse)
|
||||
async def squad_actions(
|
||||
squad_uuid: str,
|
||||
payload: RemnaWaveSquadActionRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveOperationResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
action = payload.action
|
||||
success = False
|
||||
detail = "Неизвестное действие"
|
||||
|
||||
if action == "add_all_users":
|
||||
success = await service.add_all_users_to_squad(squad_uuid)
|
||||
detail = "Пользователи добавлены" if success else "Не удалось добавить пользователей"
|
||||
elif action == "remove_all_users":
|
||||
success = await service.remove_all_users_from_squad(squad_uuid)
|
||||
detail = "Пользователи удалены" if success else "Не удалось удалить пользователей"
|
||||
elif action == "delete":
|
||||
success = await service.delete_squad(squad_uuid)
|
||||
detail = "Сквад удален" if success else "Не удалось удалить сквад"
|
||||
elif action == "rename":
|
||||
if not payload.name:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо указать новое имя")
|
||||
success = await service.rename_squad(squad_uuid, payload.name)
|
||||
detail = "Сквад переименован" if success else "Не удалось переименовать сквад"
|
||||
elif action == "update_inbounds":
|
||||
if not payload.inbound_uuids:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо указать inbound_uuids")
|
||||
success = await service.update_squad_inbounds(squad_uuid, payload.inbound_uuids)
|
||||
detail = "Инбаунды обновлены" if success else "Не удалось обновить инбаунды"
|
||||
|
||||
return RemnaWaveOperationResponse(success=success, detail=detail)
|
||||
|
||||
|
||||
@router.get("/inbounds", response_model=RemnaWaveInboundsResponse)
|
||||
async def list_inbounds(
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveInboundsResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
inbounds = await service.get_all_inbounds()
|
||||
return RemnaWaveInboundsResponse(items=inbounds or [])
|
||||
|
||||
|
||||
@router.get("/users/{telegram_id}/traffic", response_model=RemnaWaveUserTrafficResponse)
|
||||
async def get_user_traffic(
|
||||
telegram_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> RemnaWaveUserTrafficResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.get_user_traffic_stats(telegram_id)
|
||||
if not stats:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Пользователь не найден в RemnaWave")
|
||||
|
||||
return RemnaWaveUserTrafficResponse(telegram_id=telegram_id, **stats)
|
||||
|
||||
|
||||
@router.post("/sync/from-panel", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def sync_from_panel(
|
||||
payload: RemnaWaveSyncFromPanelRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
try:
|
||||
stats = await service.sync_users_from_panel(db, payload.mode)
|
||||
detail = "Синхронизация из панели выполнена"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
||||
except Exception as exc: # pragma: no cover - точный тип зависит от импорта
|
||||
if RemnaWaveConfigurationError and isinstance(exc, RemnaWaveConfigurationError):
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/sync/to-panel", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def sync_to_panel(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.sync_users_to_panel(db)
|
||||
detail = "Синхронизация в панель выполнена"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
||||
|
||||
|
||||
@router.post("/sync/subscriptions/validate", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def validate_and_fix_subscriptions(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.validate_and_fix_subscriptions(db)
|
||||
detail = "Подписки проверены"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
||||
|
||||
|
||||
@router.post("/sync/subscriptions/cleanup", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def cleanup_orphaned_subscriptions(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.cleanup_orphaned_subscriptions(db)
|
||||
detail = "Очистка завершена"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
||||
|
||||
|
||||
@router.post("/sync/subscriptions/statuses", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def sync_subscription_statuses(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
stats = await service.sync_subscription_statuses(db)
|
||||
detail = "Статусы подписок синхронизированы"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
|
||||
|
||||
|
||||
@router.get("/sync/recommendations", response_model=RemnaWaveGenericSyncResponse)
|
||||
async def get_sync_recommendations(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RemnaWaveGenericSyncResponse:
|
||||
service = _get_service()
|
||||
_ensure_service_configured(service)
|
||||
|
||||
data = await service.get_sync_recommendations(db)
|
||||
detail = "Рекомендации получены"
|
||||
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=data)
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Security
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserStatus,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/overview",
|
||||
summary="Общая статистика",
|
||||
response_description="Агрегированные показатели пользователей, подписок, саппорта и платежей",
|
||||
responses={
|
||||
200: {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"users": {
|
||||
"total": 12345,
|
||||
"active": 9876,
|
||||
"blocked": 321,
|
||||
"balance_kopeks": 1234567,
|
||||
"balance_rubles": 12345.67,
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": 4321,
|
||||
"expired": 210,
|
||||
},
|
||||
"support": {
|
||||
"open_tickets": 42,
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": 654321,
|
||||
"today_rubles": 6543.21,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def stats_overview(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, object]:
|
||||
total_users = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||
active_users = await db.scalar(
|
||||
select(func.count()).select_from(User).where(User.status == UserStatus.ACTIVE.value)
|
||||
) or 0
|
||||
blocked_users = await db.scalar(
|
||||
select(func.count()).select_from(User).where(User.status == UserStatus.BLOCKED.value)
|
||||
) or 0
|
||||
|
||||
total_balance_kopeks = await db.scalar(
|
||||
select(func.coalesce(func.sum(User.balance_kopeks), 0))
|
||||
) or 0
|
||||
|
||||
active_subscriptions = await db.scalar(
|
||||
select(func.count()).select_from(Subscription).where(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
) or 0
|
||||
|
||||
expired_subscriptions = await db.scalar(
|
||||
select(func.count()).select_from(Subscription).where(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
)
|
||||
) or 0
|
||||
|
||||
pending_tickets = await db.scalar(
|
||||
select(func.count()).select_from(Ticket).where(
|
||||
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
|
||||
)
|
||||
) or 0
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_transactions = await db.scalar(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
func.date(Transaction.created_at) == today,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
)
|
||||
) or 0
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"active": active_users,
|
||||
"blocked": blocked_users,
|
||||
"balance_kopeks": int(total_balance_kopeks),
|
||||
"balance_rubles": round(total_balance_kopeks / 100, 2),
|
||||
},
|
||||
"subscriptions": {
|
||||
"active": active_subscriptions,
|
||||
"expired": expired_subscriptions,
|
||||
},
|
||||
"support": {
|
||||
"open_tickets": pending_tickets,
|
||||
},
|
||||
"payments": {
|
||||
"today_kopeks": int(today_transactions),
|
||||
"today_rubles": round(today_transactions / 100, 2),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import (
|
||||
add_subscription_devices,
|
||||
add_subscription_squad,
|
||||
add_subscription_traffic,
|
||||
create_paid_subscription,
|
||||
create_trial_subscription,
|
||||
extend_subscription,
|
||||
get_subscription_by_user_id,
|
||||
remove_subscription_squad,
|
||||
)
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.subscriptions import (
|
||||
SubscriptionCreateRequest,
|
||||
SubscriptionDevicesRequest,
|
||||
SubscriptionExtendRequest,
|
||||
SubscriptionResponse,
|
||||
SubscriptionSquadRequest,
|
||||
SubscriptionTrafficRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
|
||||
return SubscriptionResponse(
|
||||
id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
status=subscription.status,
|
||||
actual_status=subscription.actual_status,
|
||||
is_trial=subscription.is_trial,
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
subscription_crypto_link=subscription.subscription_crypto_link,
|
||||
connected_squads=list(subscription.connected_squads or []),
|
||||
created_at=subscription.created_at,
|
||||
updated_at=subscription.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
return subscription
|
||||
|
||||
|
||||
@router.get("", response_model=list[SubscriptionResponse])
|
||||
async def list_subscriptions(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
status_filter: Optional[SubscriptionStatus] = Query(default=None, alias="status"),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
is_trial: Optional[bool] = Query(default=None),
|
||||
) -> list[SubscriptionResponse]:
|
||||
query = select(Subscription).options(selectinload(Subscription.user))
|
||||
|
||||
if status_filter:
|
||||
query = query.where(Subscription.status == status_filter.value)
|
||||
if user_id:
|
||||
query = query.where(Subscription.user_id == user_id)
|
||||
if is_trial is not None:
|
||||
query = query.where(Subscription.is_trial.is_(is_trial))
|
||||
|
||||
query = query.order_by(Subscription.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
subscriptions = result.scalars().all()
|
||||
return [_serialize_subscription(sub) for sub in subscriptions]
|
||||
|
||||
|
||||
@router.get("/{subscription_id}", response_model=SubscriptionResponse)
|
||||
async def get_subscription(
|
||||
subscription_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("", response_model=SubscriptionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_subscription(
|
||||
payload: SubscriptionCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
existing = await get_subscription_by_user_id(db, payload.user_id)
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
|
||||
|
||||
if payload.is_trial:
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb,
|
||||
device_limit=payload.device_limit,
|
||||
squad_uuid=payload.squad_uuid,
|
||||
)
|
||||
else:
|
||||
if payload.duration_days is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
|
||||
subscription = await create_paid_subscription(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
connected_squads=payload.connected_squads or [],
|
||||
)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/extend", response_model=SubscriptionResponse)
|
||||
async def extend_subscription_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionExtendRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await extend_subscription(db, subscription, payload.days)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/traffic", response_model=SubscriptionResponse)
|
||||
async def add_subscription_traffic_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionTrafficRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_traffic(db, subscription, payload.gb)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/devices", response_model=SubscriptionResponse)
|
||||
async def add_subscription_devices_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionDevicesRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_devices(db, subscription, payload.devices)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/squads", response_model=SubscriptionResponse)
|
||||
async def add_subscription_squad_endpoint(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionSquadRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
if not payload.squad_uuid:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "squad_uuid is required")
|
||||
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_squad(db, subscription, payload.squad_uuid)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.delete("/{subscription_id}/squads/{squad_uuid}", response_model=SubscriptionResponse)
|
||||
async def remove_subscription_squad_endpoint(
|
||||
subscription_id: int,
|
||||
squad_uuid: str,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await remove_subscription_squad(db, subscription, squad_uuid)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.tickets import (
|
||||
TicketMessageResponse,
|
||||
TicketPriorityUpdateRequest,
|
||||
TicketReplyBlockRequest,
|
||||
TicketResponse,
|
||||
TicketStatusUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
user_id=message.user_id,
|
||||
message_text=message.message_text,
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=message.has_media,
|
||||
media_type=message.media_type,
|
||||
media_caption=message.media_caption,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_ticket(ticket: Ticket, include_messages: bool = False) -> TicketResponse:
|
||||
messages = []
|
||||
if include_messages:
|
||||
messages = sorted(ticket.messages, key=lambda m: m.created_at)
|
||||
|
||||
return TicketResponse(
|
||||
id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
title=ticket.title,
|
||||
status=ticket.status,
|
||||
priority=ticket.priority,
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at,
|
||||
closed_at=ticket.closed_at,
|
||||
user_reply_block_permanent=ticket.user_reply_block_permanent,
|
||||
user_reply_block_until=ticket.user_reply_block_until,
|
||||
messages=[_serialize_message(message) for message in messages],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TicketResponse])
|
||||
async def list_tickets(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
status_filter: Optional[TicketStatus] = Query(default=None, alias="status"),
|
||||
priority: Optional[str] = Query(default=None),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
) -> list[TicketResponse]:
|
||||
status_value = status_filter.value if status_filter else None
|
||||
|
||||
if user_id:
|
||||
tickets = await TicketCRUD.get_user_tickets(
|
||||
db,
|
||||
user_id=user_id,
|
||||
status=status_value,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
else:
|
||||
tickets = await TicketCRUD.get_all_tickets(
|
||||
db,
|
||||
status=status_value,
|
||||
priority=priority,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return [_serialize_ticket(ticket) for ticket in tickets]
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=TicketResponse)
|
||||
async def get_ticket(
|
||||
ticket_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
if not ticket:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/status", response_model=TicketResponse)
|
||||
async def update_ticket_status(
|
||||
ticket_id: int,
|
||||
payload: TicketStatusUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
try:
|
||||
status_value = TicketStatus(payload.status).value
|
||||
except ValueError as error:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid ticket status") from error
|
||||
|
||||
closed_at = datetime.utcnow() if status_value == TicketStatus.CLOSED.value else None
|
||||
success = await TicketCRUD.update_ticket_status(db, ticket_id, status_value, closed_at)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/priority", response_model=TicketResponse)
|
||||
async def update_ticket_priority(
|
||||
ticket_id: int,
|
||||
payload: TicketPriorityUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
allowed_priorities = {"low", "normal", "high", "urgent"}
|
||||
if payload.priority not in allowed_priorities:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid priority")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
if not ticket:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket.priority = payload.priority
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/reply-block", response_model=TicketResponse)
|
||||
async def update_reply_block(
|
||||
ticket_id: int,
|
||||
payload: TicketReplyBlockRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
until = payload.until
|
||||
if not payload.permanent and until and until <= datetime.utcnow():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Block expiration must be in the future")
|
||||
|
||||
success = await TicketCRUD.set_user_reply_block(
|
||||
db,
|
||||
ticket_id,
|
||||
permanent=payload.permanent,
|
||||
until=until,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
|
||||
|
||||
@router.delete("/{ticket_id}/reply-block", response_model=TicketResponse)
|
||||
async def clear_reply_block(
|
||||
ticket_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketResponse:
|
||||
success = await TicketCRUD.set_user_reply_block(
|
||||
db,
|
||||
ticket_id,
|
||||
permanent=False,
|
||||
until=None,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||
return _serialize_ticket(ticket, include_messages=True)
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.web_api_token import (
|
||||
delete_token,
|
||||
get_token_by_id,
|
||||
list_tokens,
|
||||
)
|
||||
from app.database.models import WebApiToken
|
||||
from app.services.web_api_token_service import web_api_token_service
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.tokens import TokenCreateRequest, TokenCreateResponse, TokenResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(token: WebApiToken) -> TokenResponse:
|
||||
return TokenResponse(
|
||||
id=token.id,
|
||||
name=token.name,
|
||||
prefix=token.token_prefix,
|
||||
description=token.description,
|
||||
is_active=token.is_active,
|
||||
created_at=token.created_at,
|
||||
updated_at=token.updated_at,
|
||||
expires_at=token.expires_at,
|
||||
last_used_at=token.last_used_at,
|
||||
last_used_ip=token.last_used_ip,
|
||||
created_by=token.created_by,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TokenResponse])
|
||||
async def get_tokens(
|
||||
_: WebApiToken = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[TokenResponse]:
|
||||
tokens = await list_tokens(db, include_inactive=True)
|
||||
return [_serialize(token) for token in tokens]
|
||||
|
||||
|
||||
@router.post("", response_model=TokenCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_token(
|
||||
payload: TokenCreateRequest,
|
||||
actor: WebApiToken = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TokenCreateResponse:
|
||||
token_value, token = await web_api_token_service.create_token(
|
||||
db,
|
||||
name=payload.name.strip(),
|
||||
description=payload.description,
|
||||
expires_at=payload.expires_at,
|
||||
created_by=actor.name,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
base = _serialize(token).model_dump()
|
||||
base["token"] = token_value
|
||||
return TokenCreateResponse(**base)
|
||||
|
||||
|
||||
@router.post("/{token_id}/revoke", response_model=TokenResponse)
|
||||
async def revoke_token(
|
||||
token_id: int,
|
||||
_: WebApiToken = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TokenResponse:
|
||||
token = await get_token_by_id(db, token_id)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
|
||||
|
||||
await web_api_token_service.revoke_token(db, token)
|
||||
await db.commit()
|
||||
return _serialize(token)
|
||||
|
||||
|
||||
@router.post("/{token_id}/activate", response_model=TokenResponse)
|
||||
async def activate_token(
|
||||
token_id: int,
|
||||
_: WebApiToken = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TokenResponse:
|
||||
token = await get_token_by_id(db, token_id)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
|
||||
|
||||
await web_api_token_service.activate_token(db, token)
|
||||
await db.commit()
|
||||
return _serialize(token)
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_token_endpoint(
|
||||
token_id: int,
|
||||
_: WebApiToken = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
token = await get_token_by_id(db, token_id)
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
|
||||
|
||||
await delete_token(db, token)
|
||||
await db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Security
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Transaction
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.transactions import TransactionListResponse, TransactionResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(transaction: Transaction) -> TransactionResponse:
|
||||
return TransactionResponse(
|
||||
id=transaction.id,
|
||||
user_id=transaction.user_id,
|
||||
type=transaction.type,
|
||||
amount_kopeks=transaction.amount_kopeks,
|
||||
amount_rubles=round(transaction.amount_kopeks / 100, 2),
|
||||
description=transaction.description,
|
||||
payment_method=transaction.payment_method,
|
||||
external_id=transaction.external_id,
|
||||
is_completed=transaction.is_completed,
|
||||
created_at=transaction.created_at,
|
||||
completed_at=transaction.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=TransactionListResponse)
|
||||
async def list_transactions(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
type_filter: Optional[str] = Query(default=None, alias="type"),
|
||||
payment_method: Optional[str] = Query(default=None),
|
||||
is_completed: Optional[bool] = Query(default=None),
|
||||
date_from: Optional[datetime] = Query(default=None),
|
||||
date_to: Optional[datetime] = Query(default=None),
|
||||
) -> TransactionListResponse:
|
||||
base_query = select(Transaction)
|
||||
conditions = []
|
||||
|
||||
if user_id:
|
||||
conditions.append(Transaction.user_id == user_id)
|
||||
if type_filter:
|
||||
conditions.append(Transaction.type == type_filter)
|
||||
if payment_method:
|
||||
conditions.append(Transaction.payment_method == payment_method)
|
||||
if is_completed is not None:
|
||||
conditions.append(Transaction.is_completed.is_(is_completed))
|
||||
if date_from:
|
||||
conditions.append(Transaction.created_at >= date_from)
|
||||
if date_to:
|
||||
conditions.append(Transaction.created_at <= date_to)
|
||||
|
||||
if conditions:
|
||||
base_query = base_query.where(and_(*conditions))
|
||||
|
||||
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||
total = await db.scalar(total_query) or 0
|
||||
|
||||
result = await db.execute(
|
||||
base_query.order_by(Transaction.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
transactions = result.scalars().all()
|
||||
|
||||
return TransactionListResponse(
|
||||
items=[_serialize(tx) for tx in transactions],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.user import (
|
||||
add_user_balance,
|
||||
create_user,
|
||||
get_user_by_id,
|
||||
get_user_by_referral_code,
|
||||
get_user_by_telegram_id,
|
||||
update_user,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, User, UserStatus
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.users import (
|
||||
BalanceUpdateRequest,
|
||||
PromoGroupSummary,
|
||||
SubscriptionSummary,
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserResponse,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]:
|
||||
if not group:
|
||||
return None
|
||||
return PromoGroupSummary(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
server_discount_percent=group.server_discount_percent,
|
||||
traffic_discount_percent=group.traffic_discount_percent,
|
||||
device_discount_percent=group.device_discount_percent,
|
||||
apply_discounts_to_addons=getattr(group, "apply_discounts_to_addons", True),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[SubscriptionSummary]:
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
return SubscriptionSummary(
|
||||
id=subscription.id,
|
||||
status=subscription.status,
|
||||
actual_status=subscription.actual_status,
|
||||
is_trial=subscription.is_trial,
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
subscription_crypto_link=subscription.subscription_crypto_link,
|
||||
connected_squads=list(subscription.connected_squads or []),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_user(user: User) -> UserResponse:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
status=user.status,
|
||||
language=user.language,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_rubles=round(user.balance_kopeks / 100, 2),
|
||||
referral_code=user.referral_code,
|
||||
referred_by_id=user.referred_by_id,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_made_first_topup=user.has_made_first_topup,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
last_activity=user.last_activity,
|
||||
promo_group=_serialize_promo_group(promo_group),
|
||||
subscription=_serialize_subscription(subscription),
|
||||
)
|
||||
|
||||
|
||||
def _apply_search_filter(query, search: str):
|
||||
search_lower = f"%{search.lower()}%"
|
||||
conditions = [
|
||||
func.lower(User.username).like(search_lower),
|
||||
func.lower(User.first_name).like(search_lower),
|
||||
func.lower(User.last_name).like(search_lower),
|
||||
func.lower(User.referral_code).like(search_lower),
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
conditions.append(User.id == int(search))
|
||||
|
||||
return query.where(or_(*conditions))
|
||||
|
||||
|
||||
@router.get("", response_model=UserListResponse)
|
||||
async def list_users(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
status_filter: Optional[UserStatus] = Query(default=None, alias="status"),
|
||||
promo_group_id: Optional[int] = Query(default=None),
|
||||
search: Optional[str] = Query(default=None),
|
||||
) -> UserListResponse:
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
base_query = base_query.where(User.status == status_filter.value)
|
||||
|
||||
if promo_group_id:
|
||||
base_query = base_query.where(User.promo_group_id == promo_group_id)
|
||||
|
||||
if search:
|
||||
base_query = _apply_search_filter(base_query, search)
|
||||
|
||||
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||
total = await db.scalar(total_query) or 0
|
||||
|
||||
result = await db.execute(
|
||||
base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
return UserListResponse(
|
||||
items=[_serialize_user(user) for user in users],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
return _serialize_user(user)
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user_endpoint(
|
||||
payload: UserCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
existing = await get_user_by_telegram_id(db, payload.telegram_id)
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User with this telegram_id already exists")
|
||||
|
||||
user = await create_user(
|
||||
db,
|
||||
telegram_id=payload.telegram_id,
|
||||
username=payload.username,
|
||||
first_name=payload.first_name,
|
||||
last_name=payload.last_name,
|
||||
language=payload.language,
|
||||
referred_by_id=payload.referred_by_id,
|
||||
)
|
||||
|
||||
if payload.promo_group_id and payload.promo_group_id != user.promo_group_id:
|
||||
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
|
||||
if not promo_group:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
|
||||
user = await update_user(db, user, promo_group_id=promo_group.id)
|
||||
|
||||
user = await get_user_by_id(db, user.id)
|
||||
return _serialize_user(user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user_endpoint(
|
||||
user_id: int,
|
||||
payload: UserUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
|
||||
if payload.username is not None:
|
||||
updates["username"] = payload.username
|
||||
if payload.first_name is not None:
|
||||
updates["first_name"] = payload.first_name
|
||||
if payload.last_name is not None:
|
||||
updates["last_name"] = payload.last_name
|
||||
if payload.language is not None:
|
||||
updates["language"] = payload.language
|
||||
if payload.has_had_paid_subscription is not None:
|
||||
updates["has_had_paid_subscription"] = payload.has_had_paid_subscription
|
||||
if payload.has_made_first_topup is not None:
|
||||
updates["has_made_first_topup"] = payload.has_made_first_topup
|
||||
|
||||
if payload.status is not None:
|
||||
try:
|
||||
status_value = UserStatus(payload.status).value
|
||||
except ValueError as error:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid status") from error
|
||||
updates["status"] = status_value
|
||||
|
||||
if payload.promo_group_id is not None:
|
||||
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
|
||||
if not promo_group:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
|
||||
updates["promo_group_id"] = promo_group.id
|
||||
|
||||
if payload.referral_code is not None and payload.referral_code != user.referral_code:
|
||||
existing_code_owner = await get_user_by_referral_code(db, payload.referral_code)
|
||||
if existing_code_owner and existing_code_owner.id != user.id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Referral code already in use")
|
||||
updates["referral_code"] = payload.referral_code
|
||||
|
||||
if not updates:
|
||||
return _serialize_user(user)
|
||||
|
||||
user = await update_user(db, user, **updates)
|
||||
user = await get_user_by_id(db, user.id)
|
||||
return _serialize_user(user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/balance", response_model=UserResponse)
|
||||
async def update_balance(
|
||||
user_id: int,
|
||||
payload: BalanceUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
if payload.amount_kopeks == 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be non-zero")
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks=payload.amount_kopeks,
|
||||
description=payload.description or "Корректировка через веб-API",
|
||||
create_transaction=payload.create_transaction,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to update balance")
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
return _serialize_user(user)
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BackupCreateResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str = Field(..., description="Текущий статус задачи")
|
||||
|
||||
|
||||
class BackupInfo(BaseModel):
|
||||
filename: str
|
||||
filepath: str
|
||||
timestamp: Optional[datetime] = None
|
||||
tables_count: Optional[int] = None
|
||||
total_records: Optional[int] = None
|
||||
compressed: bool
|
||||
file_size_bytes: int
|
||||
file_size_mb: float
|
||||
created_by: Optional[int] = None
|
||||
database_type: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class BackupListResponse(BaseModel):
|
||||
items: list[BackupInfo]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class BackupStatusResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str
|
||||
message: Optional[str] = None
|
||||
file_path: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Полный путь до созданного бекапа, если задача завершена",
|
||||
)
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BackupTaskInfo(BackupStatusResponse):
|
||||
pass
|
||||
|
||||
|
||||
class BackupTaskListResponse(BaseModel):
|
||||
items: list[BackupTaskInfo]
|
||||
total: int
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS
|
||||
|
||||
|
||||
class BroadcastMedia(BaseModel):
|
||||
type: str = Field(pattern=r"^(photo|video|document)$")
|
||||
file_id: str
|
||||
caption: Optional[str] = None
|
||||
|
||||
|
||||
class BroadcastCreateRequest(BaseModel):
|
||||
target: str
|
||||
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||
selected_buttons: list[str] = Field(
|
||||
default_factory=lambda: list(DEFAULT_BROADCAST_BUTTONS)
|
||||
)
|
||||
media: Optional[BroadcastMedia] = None
|
||||
|
||||
_ALLOWED_TARGETS: ClassVar[set[str]] = {
|
||||
"all",
|
||||
"active",
|
||||
"trial",
|
||||
"no",
|
||||
"expiring",
|
||||
"expired",
|
||||
"active_zero",
|
||||
"trial_zero",
|
||||
"zero",
|
||||
}
|
||||
_CUSTOM_TARGETS: ClassVar[set[str]] = {
|
||||
"today",
|
||||
"week",
|
||||
"month",
|
||||
"active_today",
|
||||
"inactive_week",
|
||||
"inactive_month",
|
||||
"referrals",
|
||||
"direct",
|
||||
}
|
||||
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
|
||||
"no_sub": "no",
|
||||
}
|
||||
|
||||
@validator("target")
|
||||
def validate_target(cls, value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
normalized = cls._TARGET_ALIASES.get(normalized, normalized)
|
||||
|
||||
if normalized in cls._ALLOWED_TARGETS:
|
||||
return normalized
|
||||
|
||||
if normalized.startswith("custom_"):
|
||||
criteria = normalized[len("custom_"):]
|
||||
if criteria in cls._CUSTOM_TARGETS:
|
||||
return normalized
|
||||
|
||||
raise ValueError("Unsupported target value")
|
||||
|
||||
@validator("selected_buttons", pre=True)
|
||||
def validate_selected_buttons(cls, value):
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise TypeError("selected_buttons must be an array")
|
||||
|
||||
seen = set()
|
||||
ordered: list[str] = []
|
||||
for raw_button in value:
|
||||
button = str(raw_button).strip()
|
||||
if not button:
|
||||
continue
|
||||
if button not in BROADCAST_BUTTONS:
|
||||
raise ValueError(f"Unsupported button '{button}'")
|
||||
if button in seen:
|
||||
continue
|
||||
ordered.append(button)
|
||||
seen.add(button)
|
||||
return ordered
|
||||
|
||||
|
||||
class BroadcastResponse(BaseModel):
|
||||
id: int
|
||||
target_type: str
|
||||
message_text: str
|
||||
has_media: bool
|
||||
media_type: Optional[str] = None
|
||||
media_file_id: Optional[str] = None
|
||||
media_caption: Optional[str] = None
|
||||
total_count: int
|
||||
sent_count: int
|
||||
failed_count: int
|
||||
status: str
|
||||
admin_id: Optional[int] = None
|
||||
admin_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class BroadcastListResponse(BaseModel):
|
||||
items: list[BroadcastResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
CampaignBonusType = Annotated[Literal["balance", "subscription"], Field(description="Тип бонуса кампании")]
|
||||
|
||||
|
||||
class CampaignBase(BaseModel):
|
||||
name: str = Field(..., max_length=255)
|
||||
start_parameter: str = Field(..., max_length=64, description="Start parameter для deep-link (уникальный)")
|
||||
bonus_type: CampaignBonusType
|
||||
balance_bonus_kopeks: int = Field(0, ge=0)
|
||||
subscription_duration_days: Optional[int] = Field(None, ge=0)
|
||||
subscription_traffic_gb: Optional[int] = Field(None, ge=0)
|
||||
subscription_device_limit: Optional[int] = Field(None, ge=0)
|
||||
subscription_squads: list[str] = Field(default_factory=list)
|
||||
|
||||
@validator("name", "start_parameter")
|
||||
def strip_strings(cls, value: str) -> str: # noqa: D401,B902
|
||||
return value.strip()
|
||||
|
||||
|
||||
class CampaignCreateRequest(CampaignBase):
|
||||
is_active: bool = True
|
||||
|
||||
@validator("balance_bonus_kopeks")
|
||||
def validate_balance_bonus(cls, value: int, values: dict) -> int: # noqa: D401,B902
|
||||
if values.get("bonus_type") == "balance" and value <= 0:
|
||||
raise ValueError("balance_bonus_kopeks must be positive for balance bonus")
|
||||
return value
|
||||
|
||||
@validator("subscription_duration_days")
|
||||
def validate_subscription_bonus(cls, value: Optional[int], values: dict): # noqa: D401,B902
|
||||
if values.get("bonus_type") == "subscription":
|
||||
if value is None or value <= 0:
|
||||
raise ValueError("subscription_duration_days must be positive for subscription bonus")
|
||||
return value
|
||||
|
||||
|
||||
class CampaignResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
bonus_type: CampaignBonusType
|
||||
balance_bonus_kopeks: int
|
||||
balance_bonus_rubles: float
|
||||
subscription_duration_days: Optional[int] = None
|
||||
subscription_traffic_gb: Optional[int] = None
|
||||
subscription_device_limit: Optional[int] = None
|
||||
subscription_squads: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
registrations_count: int = 0
|
||||
|
||||
|
||||
class CampaignListResponse(BaseModel):
|
||||
items: list[CampaignResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class CampaignUpdateRequest(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
start_parameter: Optional[str] = Field(None, max_length=64)
|
||||
bonus_type: Optional[CampaignBonusType] = None
|
||||
balance_bonus_kopeks: Optional[int] = Field(None, ge=0)
|
||||
subscription_duration_days: Optional[int] = Field(None, ge=0)
|
||||
subscription_traffic_gb: Optional[int] = Field(None, ge=0)
|
||||
subscription_device_limit: Optional[int] = Field(None, ge=0)
|
||||
subscription_squads: Optional[list[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@validator("name", "start_parameter", pre=True)
|
||||
def strip_optional_strings(cls, value: Optional[str]): # noqa: D401,B902
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
@validator("balance_bonus_kopeks")
|
||||
def validate_balance_bonus(cls, value: Optional[int], values: dict): # noqa: D401,B902
|
||||
bonus_type = values.get("bonus_type")
|
||||
if bonus_type == "balance" and value is not None and value <= 0:
|
||||
raise ValueError("balance_bonus_kopeks must be positive for balance bonus")
|
||||
return value
|
||||
|
||||
@validator("subscription_duration_days")
|
||||
def validate_subscription_bonus(cls, value: Optional[int], values: dict): # noqa: D401,B902
|
||||
bonus_type = values.get("bonus_type")
|
||||
if bonus_type == "subscription" and value is not None and value <= 0:
|
||||
raise ValueError("subscription_duration_days must be positive for subscription bonus")
|
||||
return value
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SettingCategorySummary(BaseModel):
|
||||
"""Краткое описание категории настройки."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
items: int
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingCategoryRef(BaseModel):
|
||||
"""Ссылка на категорию, к которой относится настройка."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingChoice(BaseModel):
|
||||
"""Вариант значения для настройки с выбором."""
|
||||
|
||||
value: Any
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingDefinition(BaseModel):
|
||||
"""Полное описание настройки и её текущего состояния."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
category: SettingCategoryRef
|
||||
type: str
|
||||
is_optional: bool
|
||||
current: Any | None = Field(default=None)
|
||||
original: Any | None = Field(default=None)
|
||||
has_override: bool
|
||||
choices: list[SettingChoice] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingUpdateRequest(BaseModel):
|
||||
"""Запрос на обновление значения настройки."""
|
||||
|
||||
value: Any
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class HealthFeatureFlags(BaseModel):
|
||||
"""Флаги доступности функций административного API."""
|
||||
|
||||
monitoring: bool
|
||||
maintenance: bool
|
||||
reporting: bool
|
||||
webhooks: bool
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class HealthCheckResponse(BaseModel):
|
||||
"""Ответ на health-check административного API."""
|
||||
|
||||
status: str
|
||||
api_version: str
|
||||
bot_version: str | None
|
||||
features: HealthFeatureFlags
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user