Compare commits
453 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd1e3f107 | |||
| 6c6ade4f7f | |||
| 30ad692bc2 | |||
| 24b3ea4881 | |||
| 560689d9ee | |||
| aa763403c6 | |||
| 96c0c3562d | |||
| e48eaf584c | |||
| 790cd6bab3 | |||
| 55ae2bd91c | |||
| d431931501 | |||
| 425d15c850 | |||
| 58affda938 | |||
| c4eb2a60de | |||
| 46dca8c095 | |||
| b752278398 | |||
| 6a659a2131 | |||
| 019397b0ea | |||
| 947a400909 | |||
| 7f52beb2a2 | |||
| 283d42af86 | |||
| d36d1e3041 | |||
| a83bc25bd0 | |||
| 3555ada3b5 | |||
| 1cb93d9688 | |||
| 6f9fc5e071 | |||
| 96d466328c | |||
| 846e7844db | |||
| 4e0b0837d9 | |||
| 8ba5cda095 | |||
| 7ac33bce28 | |||
| d8a0c166ac | |||
| c1858fe925 | |||
| dae8e4c34c | |||
| 3d7c219fd1 | |||
| 03781463e4 | |||
| dcd9074136 | |||
| 7fbd26c8c3 | |||
| 9677a55375 | |||
| eb51cda617 | |||
| 09bdde6f91 | |||
| 784b2ed996 | |||
| 9bee4a2365 | |||
| f8946b99f3 | |||
| a17cbf4448 | |||
| b0422e0c0f | |||
| 9d0bf8b3cf | |||
| 8c5e8ca019 | |||
| 0327adb8da | |||
| 5769fa63a5 | |||
| 1d09006b7c | |||
| e194ab9729 | |||
| 45a6173916 | |||
| b21baba006 | |||
| 45c1b4695c | |||
| 9b2b28423d | |||
| 9d00c2ddc6 | |||
| dc66676663 | |||
| 1d600e2c63 | |||
| 5ce177a7b5 | |||
| d181a333d7 | |||
| 3694cb4ed7 | |||
| 051048e0ae | |||
| 61ee6e42cf | |||
| 5bd618a799 | |||
| 7794a4cc7b | |||
| 6d01d32dc2 | |||
| be71def203 | |||
| 7a2de410bd | |||
| b019e8b32d | |||
| cb4f773eb1 | |||
| 29fe9f4943 | |||
| 63edfa852d | |||
| 66fabb6836 | |||
| f29ea49185 | |||
| 7620a3df92 | |||
| 577bcfdc88 | |||
| 33ec09edd0 | |||
| 7b3c463fa2 | |||
| 0e541af5e1 | |||
| e39822e732 | |||
| c7973e7b58 | |||
| 4941052d57 | |||
| 0022abc6e0 | |||
| acef77b0e8 | |||
| 4ddd580347 | |||
| 42cc4db077 | |||
| 8e3f976441 | |||
| 190d9ae13e | |||
| d6deec1a29 | |||
| e8dcbd09ff | |||
| 382f1eb6f7 | |||
| 2853410ebc | |||
| 2bde6a83ed | |||
| 83b1fa0f0f | |||
| 3a2a05d307 | |||
| 2460766e30 | |||
| 3eb01eaabc | |||
| 35bca4166d | |||
| f2db938fdc | |||
| ff0c68afc6 | |||
| 67babf7566 | |||
| f71788d2d4 | |||
| 5eebdcc85c | |||
| 4b89de9634 | |||
| b81d5b199b | |||
| a21559cd0d | |||
| 6cd3f87318 | |||
| f9e416341f | |||
| 8a2149d2f8 | |||
| e0f6e3b1ef | |||
| 8948f4d302 | |||
| d13ff6830b | |||
| b8aee33e88 | |||
| 17639f3a67 | |||
| cb77b5a21c | |||
| 012ba2049e | |||
| 6acfcc9966 | |||
| f6839c4184 | |||
| 1fbdcb7391 | |||
| 50a65d06d4 | |||
| 20855e5bfc | |||
| 2e4317e457 | |||
| 89dd41ef43 | |||
| 7674b0325b | |||
| 8a5141b58c | |||
| a819be0b5a | |||
| 7de4730822 | |||
| b4815b291e | |||
| 68da3d7d30 | |||
| ca13385dee | |||
| 9216128eb6 | |||
| 3d1fe09540 | |||
| 07b8e04afe | |||
| 01039a969a | |||
| a020db5bc6 | |||
| a91baf2acd | |||
| 1caa2b2ba2 | |||
| a7e9f732a2 | |||
| c8794722a2 | |||
| bbdb3e454e | |||
| fa693494d0 | |||
| 02c38a25ea | |||
| d5d60a8c7a | |||
| 864aaae017 | |||
| 413173270d | |||
| b8eb6606f1 | |||
| 83226c8f3d | |||
| 8a96cddb4f | |||
| e32f3191e4 | |||
| acb5c3c1d7 | |||
| cf13b49b3c | |||
| 2049765969 | |||
| 6b2af1f4a3 | |||
| 00d080cbfb | |||
| d0ce5438a8 | |||
| 05914c4e14 | |||
| a040d03df0 | |||
| a29e0d5fb4 | |||
| ea7697fd66 | |||
| ab86296256 | |||
| 9bb384788f | |||
| 6fc4eb033b | |||
| 8a8abf5b96 | |||
| 01e68bec68 | |||
| 7b6488d166 | |||
| 6c4e4b366a | |||
| 1defa938ba | |||
| efaac8ff76 | |||
| 9246312c68 | |||
| d4bbea616f | |||
| 1be8e1a60f | |||
| 0f1147a226 | |||
| dc7d0fa8d6 | |||
| 1fc4686490 | |||
| 8378020a3e | |||
| bce31ea9a4 | |||
| 5b4337a436 | |||
| ac0953a221 | |||
| bfdb17d653 | |||
| 3159e80897 | |||
| 35d3fb1916 | |||
| 4be51bbe51 | |||
| 2416bd6ad5 | |||
| b3697b1871 | |||
| 483fab8427 | |||
| 95ae04d553 | |||
| a6d8e77f9e | |||
| ae4814d1cb | |||
| fef4f81744 | |||
| 65057348e9 | |||
| 4d32e509e0 | |||
| 8e336e1f7b | |||
| ee7078705b | |||
| 48fe845ff4 | |||
| 99c882110a | |||
| a4f1323a81 | |||
| aad1bb8805 | |||
| d2eb1d35db | |||
| d6fa2a8f3c | |||
| 32b074efd4 | |||
| 787d801722 | |||
| 91c3f2766a | |||
| 911a20a4e3 | |||
| 8e04a0e2b5 | |||
| 6225463af1 | |||
| d4badacd4a | |||
| 63bf04bb70 | |||
| 2529c9bca2 | |||
| bc09d2f610 | |||
| d530d7a296 | |||
| bea0977a86 | |||
| 634b2782d8 | |||
| 0e28cfa326 | |||
| 3d2ca8a02d | |||
| e0e673e7bb | |||
| 0d4cea1e22 | |||
| b643f45c01 | |||
| b5b30a07b8 | |||
| 09ea9b0193 | |||
| cf76ac7893 | |||
| 83b370669e | |||
| 6735cf17cf | |||
| 422686dd2c | |||
| 147dc0343c | |||
| cdff9c4666 | |||
| 25db434e47 | |||
| 5a8aeadcd9 | |||
| bdceb9c3cf | |||
| f43e0daa69 | |||
| c24c668299 | |||
| 41df40eed3 | |||
| b1975cc3ff | |||
| 369e2c3c36 | |||
| e2ec4b05db | |||
| 7493b13348 | |||
| 0a65aa16cd | |||
| 64a4ece0fe | |||
| 71664cca3e | |||
| b21177f685 | |||
| cde6aa4c26 | |||
| 5264fc0d90 | |||
| 7aa2004f27 | |||
| 40a34d1e55 | |||
| 4204b459e2 | |||
| cbe80e9447 | |||
| 0cfd8d3c7b | |||
| ddfa61c56f | |||
| 7e340bc13a | |||
| f7dd3be27c | |||
| 3ff06ed9d1 | |||
| 08d6a26b23 | |||
| 37e472d78a | |||
| 8cc04e01e9 | |||
| c199d04b91 | |||
| 102d5d0a9c | |||
| 8e7b44811e | |||
| ab1be9943b | |||
| 61b702272b | |||
| 84bce0043d | |||
| f54528f2d5 | |||
| b66911f4f7 | |||
| 352c14331d | |||
| 7ea91f344c | |||
| d2d5b6c823 | |||
| 4405c2287a | |||
| 288a72f739 | |||
| 60391a8b89 | |||
| f2d4da9514 | |||
| 648ad24e6d | |||
| 5d28cd9930 | |||
| 43974d1685 | |||
| 39ccc7fb4a | |||
| d391a46b0e | |||
| 59dd638dd7 | |||
| 3062061270 | |||
| 5b0a64964e | |||
| 70e3517d3a | |||
| 08b64abb03 | |||
| 84a3179974 | |||
| fa56ad8791 | |||
| f584cb78f1 | |||
| ff3ac2b9f3 | |||
| deabcfba28 | |||
| 85775604a8 | |||
| bf9b99818d | |||
| 93a8c182bc | |||
| 7febaf9db2 | |||
| 21583279c6 | |||
| df11e09d3a | |||
| 0b2ebbcdd8 | |||
| 28be0954f9 | |||
| 44164b2948 | |||
| d9d4f171f6 | |||
| 4c73d1d0b6 | |||
| 0bf71971f9 | |||
| ff19fb8950 | |||
| 68a37fcf72 | |||
| 2caf16ccc6 | |||
| b38ee36698 | |||
| 3ee9c3de7b | |||
| 0fce289926 | |||
| b1e7f83d11 | |||
| ff28f82240 | |||
| d5082be405 | |||
| 3e830a32ea | |||
| c8a99ceed7 | |||
| 602ddd4013 | |||
| 33443955c7 | |||
| 8f8b6ac1fe | |||
| 8bb0795b1e | |||
| 0055560d3e | |||
| dcc209deea | |||
| a2cb7527f8 | |||
| 523c6ab69c | |||
| 91220b0b5f | |||
| f87dac8efa | |||
| 44b75be97a | |||
| 500ed4cdf9 | |||
| 498bd77076 | |||
| 29afb6e67f | |||
| 01d10b58dd | |||
| cf7aed9abf | |||
| 1b57531b28 | |||
| 375c096e82 | |||
| 89b0daae1d | |||
| 754c21a32f | |||
| 4928cdde43 | |||
| 057e5f6ab0 | |||
| 6d66d6f627 | |||
| be28e9bec4 | |||
| b923c4bcaa | |||
| 3a749c1864 | |||
| d16d5b7918 | |||
| 8484515722 | |||
| b9efff6e97 | |||
| 061557c438 | |||
| df09ccfbd3 | |||
| 16c7e140d0 | |||
| 967924a1ff | |||
| e028a25df2 | |||
| d3fcd11201 | |||
| 97b9056b0b | |||
| 00ba52672b | |||
| efe16b4269 | |||
| 5e94ad2189 | |||
| 53bb582c81 | |||
| 4c20ec19ab | |||
| a7042ea012 | |||
| f700285415 | |||
| ed1fc22f6b | |||
| c3c0c764fd | |||
| 746e810c58 | |||
| 2df5d21a9e | |||
| d664b99e1c | |||
| 74fe3b09a2 | |||
| efea0e8414 | |||
| 1dc4d0e04d | |||
| 84f06a96b3 | |||
| 4a7cc3306a | |||
| 5d576bc727 | |||
| 19a7a8e6b1 | |||
| 064f64afd0 | |||
| bfa5e5aab9 | |||
| f59bda57cf | |||
| 6887c265ad | |||
| 7db31ff5b5 | |||
| 6bee1bb85d | |||
| c604c5f6ff | |||
| ed5e6aa550 | |||
| 6b78d0f63e | |||
| fea1819ef8 | |||
| 45586277ab | |||
| 8ff3383246 | |||
| 7879dc5e43 | |||
| 392084e436 | |||
| 256e1cb778 | |||
| e131f77de3 | |||
| a60082e5e5 | |||
| 81bb8f8f4a | |||
| a9af75d9df | |||
| 30758848fa | |||
| 00c024bd2c | |||
| a69d28fb13 | |||
| ac818ccf34 | |||
| 533335bad9 | |||
| 37c99ae1fe | |||
| d9b232b87b | |||
| b8df611d0d | |||
| e0b5beed02 | |||
| f92cf97f63 | |||
| 9ae11bc525 | |||
| ef1426a938 | |||
| 456b02e2d9 | |||
| abb156a7b1 | |||
| ca8917a43c | |||
| 8ce75007ff | |||
| 76ced9aeeb | |||
| 42eeb67039 | |||
| c11be659b3 | |||
| 2a44d98015 | |||
| 885fa2fb1a | |||
| 8bcc2135ca | |||
| a26d2fb50b | |||
| ad3a8b3af3 | |||
| 918a450e63 | |||
| 596053c720 | |||
| abb83b43bc | |||
| 6f67a36a58 | |||
| 9cbc9bb3d5 | |||
| 1cbf3c5643 | |||
| 8f35d56e6a | |||
| b5773ba668 | |||
| fa5847ee64 | |||
| b561fbc9e5 | |||
| 305a0b6b95 | |||
| 12a16097cf | |||
| c8986bea40 | |||
| bcf01ecf6a | |||
| a334fbffb7 | |||
| fc8f55195f | |||
| dbd79cbc65 | |||
| 436686dd1b | |||
| 227e754f4a | |||
| 3d43358b86 | |||
| f05f32b92e | |||
| 7628b9aaa7 | |||
| 9ab8f1dee9 | |||
| 092ea034ec | |||
| c4e5261b31 | |||
| 2baf2b9abb | |||
| 87a6b05e54 | |||
| 0925dfdf80 | |||
| cf294fae0a | |||
| 12e77cd6bf | |||
| 867ae1b807 | |||
| db9f91b336 | |||
| df0792d4dd | |||
| 0e0569fe23 | |||
| daa37370e3 | |||
| b35c731ba5 | |||
| 06879089a1 | |||
| b96d530808 | |||
| 8f3c8f37c1 | |||
| e4c7a9cf15 | |||
| 935e2356db | |||
| 3b392a671d | |||
| 4ceb09d609 | |||
| 3f8ea7f8fd | |||
| 95525f2132 | |||
| d15b46dede | |||
| 7129625ab5 | |||
| e480356447 |
@@ -147,6 +147,7 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
DEFAULT_AUTOPAY_ENABLED=true
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
@@ -161,6 +162,7 @@ TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_HOST=0.0.0.0
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
@@ -211,6 +213,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
|
||||
@@ -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.3-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-$(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.3-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-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.3-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-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.3-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.3-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.3-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.7-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.4.3"
|
||||
ARG VERSION="v2.4.7"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + PayPalych + P2P
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + YooKassa + MulenPay + PayPalych (СБП)
|
||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
|
||||
@@ -155,7 +155,7 @@ docker compose logs
|
||||
2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол).
|
||||
3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами:
|
||||
```env
|
||||
WEBHOOK_URL=https://hooks.example.com/webhook
|
||||
WEBHOOK_URL=https://hooks.example.com
|
||||
WEB_API_ENABLED=true
|
||||
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
|
||||
MINIAPP_CUSTOM_URL=https://miniapp.example.com
|
||||
@@ -696,6 +696,7 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
DEFAULT_AUTOPAY_ENABLED=true
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
@@ -710,6 +711,7 @@ TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_HOST=0.0.0.0
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
@@ -760,6 +762,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
@@ -949,105 +952,111 @@ WEBHOOK_PATH=/webhook
|
||||
|
||||
### 👤 **Для пользователей**
|
||||
|
||||
🧭 **Онбординг и доступ**
|
||||
- 🌐 Выбор языка интерфейса, динамическая локализация и настройка кнопки меню.
|
||||
- 📜 Принятие правил, оферты и политики конфиденциальности прямо в боте.
|
||||
- 📡 Проверка подписки на обязательный канал, deeplink-инвайты и UTM-кампании.
|
||||
- 👤 Сбор контактных данных с антиспам-защитой и быстрым восстановлением доступа.
|
||||
|
||||
🛒 **Умная покупка подписок**
|
||||
- 📅 Гибкие периоды (14-360 дней)
|
||||
- 📊 Выбор трафика или фиксированный лимит
|
||||
- 🌍 Автоматический выбор серверов (для одного сквада) или ручной выбор из множества
|
||||
- 📱 Настройка количества устройств и серверов прямо при оформлении
|
||||
- 🧾 Понятный калькулятор стоимости с учетом всех опций
|
||||
- 📅 Гибкие периоды (14–360 дней) и скидки за длительные подписки.
|
||||
- 📊 Выбор трафика: фиксированный лимит, квоты или безлимит.
|
||||
- 🌍 Автоматический выбор сквада или мультивыбор из десятков серверов.
|
||||
- 📱 Настройка количества устройств, стран и доп. услуг прямо в чек-ауте.
|
||||
- 🧾 Динамический калькулятор стоимости, черновики и возвращение к незавершённым заказам.
|
||||
|
||||
🧪 **Тестовая подписка**
|
||||
- Настраиваемый триал-период
|
||||
- Уведомления об истечении и сценарий мягкой конверсии в платную подписку
|
||||
- Приветственное сообщение с предложением активации триала (с возможностью отключения)
|
||||
- Поддержка рекламных кампаний с автоматической выдачей бонусных подписок
|
||||
- Гибко настраиваемый триал и welcome-цепочка.
|
||||
- Уведомления об истечении и автоконверсия в платный тариф.
|
||||
- Автовыдача бонусов за участие в кампаниях и инвайт-линках.
|
||||
- Контроль за обязательной подпиской на канал даже в режиме триала.
|
||||
|
||||
💰 **Удобные платежи**
|
||||
- ⭐ Telegram Stars
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa (включая СБП и онлайн-чек)
|
||||
- 💳 MulenPay
|
||||
- 💳 PayPalych (Pal24)
|
||||
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- Детальная история транзакций и чеков
|
||||
💰 **Платежи и баланс**
|
||||
- ⭐ Telegram Stars, 💳 Tribute, 💳 YooKassa (СБП + онлайн-чек), 💳 MulenPay, 🏦 PayPalych (СБП), 💰 CryptoBot.
|
||||
- 📥 Автогенерация счетов, контроль статусов через вебхуки и уведомления.
|
||||
- 💼 История операций с фильтрами, квитанциями и выгрузкой чеков.
|
||||
- 🔄 Автоплатёж с настройкой дня списания и уведомлениями об ошибках.
|
||||
- 🎁 Реферальные, промо- и кэшбэк-бонусы автоматически начисляются на баланс.
|
||||
|
||||
📱 **Управление подписками**
|
||||
- 📈 Статистика использования в реальном времени (трафик, устройства, серверы)
|
||||
- 🔄 Автопродление с баланса с настройкой дней и мгновенными уведомлениями
|
||||
- 🔄 Управление трафиком и лимитами без участия оператора
|
||||
- 🌍 Переключение серверов Remnawave на лету
|
||||
- 📱 Управление устройствами + сброс HWID
|
||||
- 🧩 Смена языка и персональных настроек
|
||||
- 📈 Реальный трафик, устройства и география серверов прямо в диалоге.
|
||||
- 🌍 Переключение серверов, стран и лимитов без помощи оператора.
|
||||
- 📱 Сброс HWID, повторная выдача конфигов и инструкции подключения.
|
||||
- 🧩 Смена языка, промогруппы и персональных параметров.
|
||||
- 🧾 Просмотр активных услуг, статуса оплаты и сроков действия.
|
||||
|
||||
🎁 **Бонусная система**
|
||||
- 🎫 Промокоды на деньги/дни/длинный триал
|
||||
- 👥 Защищенная реферальная программа с комиссиями и антифродом
|
||||
- 📊 Детальная статистика рефералов и кампаний
|
||||
- 📨 Персональные рассылки и уведомления о новых бонусах
|
||||
🛟 **Поддержка и самообслуживание**
|
||||
- 🎫 Тикеты с приоритетами, вложениями и уведомлениями об ответе.
|
||||
- 📚 FAQ, правила, оферта и политика доступны из меню.
|
||||
- 💬 Быстрые ссылки на живую поддержку или кастомные каналы связи.
|
||||
|
||||
🧩 **Бонусы и рефералы**
|
||||
- 🎫 Промокоды на деньги, дни, устройства, страны и расширенные триалы.
|
||||
- 👥 Реферальная программа с комиссиями, бонусами и антифрод-фильтрами.
|
||||
- 🧾 Подробная аналитика доходов, конверсии и активности приглашённых.
|
||||
- 🔗 Генерация реферальных ссылок и QR-кодов в один тап.
|
||||
|
||||
🪪 **Mini App и инструкции**
|
||||
- 📱 Telegram WebApp с обзором подписки, конфигами и кнопкой «Подключить».
|
||||
- 📥 Библиотека загрузочных ссылок для клиентов Happ и кастомных гайдов.
|
||||
- 🛰️ Web API для получения статуса подписки в мини-приложениях и внешних витринах.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ⚙️ **Для администраторов**
|
||||
|
||||
📊 **Мощная аналитика**
|
||||
- 👥 Детальная статистика пользователей и подписок
|
||||
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot)
|
||||
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
|
||||
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
|
||||
📊 **Аналитика и отчётность**
|
||||
- 📈 Дашборды по пользователям, подпискам, трафику и удержанию.
|
||||
- 💰 Детализация платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot).
|
||||
- 🧮 Продажи по тарифам, устройствам, странам и промогруппам.
|
||||
- 📣 Эффективность кампаний, промокодов, UTM-источников и реферальных потоков.
|
||||
|
||||
👥 **Управление пользователями**
|
||||
- 🔍 Поиск и редактирование профилей
|
||||
- 💰 Управление балансами и ручные начисления
|
||||
- 📱 Изменение лимита устройств (1-X)
|
||||
- 📊 Настройка лимитов трафика (0-10000 ГБ) и подключенных сквадов
|
||||
- 🌍 Мультивыбор серверов и мгновенная синхронизация с панелью
|
||||
- 🔄 Сброс HWID устройств и обновление конфигураций
|
||||
- 👥 Детальная реферальная статистика в профиле пользователя
|
||||
- 🔄 Изменение типа подписки триал/платная и ручное продление
|
||||
- 🚫 Блокировка/разблокировка/удаление аккаунтов
|
||||
- 🔍 Поиск, фильтры и детальные карточки с историей операций.
|
||||
- 💰 Ручное изменение баланса, компенсации и корректировки.
|
||||
- 📱 Изменение лимитов устройств, трафика, стран и подключенных сквадов.
|
||||
- 🔄 Сброс HWID, перегенерация подписки и синхронизация с Remnawave.
|
||||
- 🎯 Назначение промогрупп, тарифов и ручных продлений/обновлений.
|
||||
- 🚫 Блокировки с таймером, полное удаление и аудит действий персонала.
|
||||
|
||||
🎯 **Маркетинг и продажи**
|
||||
- 🎫 Гибкая промо-система (деньги/дни/длинный триал)
|
||||
- 📣 Рекламные кампании с автоматическими бонусами за регистрацию
|
||||
- 📊 Детальная статистика использования промокодов и кампаний
|
||||
- 📨 Рассылки по сегментам с фильтрами по активности, подпискам и языкам
|
||||
🎯 **Продажи, маркетинг и удержание**
|
||||
- 🎫 Промокоды (разовые/многоразовые), бонусные предложения и бесплатные пакеты.
|
||||
- 💳 Промогруппы со скидками на серверы, трафик, устройства и допуслуги.
|
||||
- 📣 Кампании с deeplink-ссылками, UTM и автопродвижением триалов.
|
||||
- 📨 Рассылки по сегментам, кастомные кнопки и медиа, отмена «в полёте».
|
||||
- 🔘 Настройка кнопок главного меню, приветственных экранов и мини-приложений.
|
||||
|
||||
🖥️ **Умный мониторинг**
|
||||
- 💚 Контроль доступности Remnawave панели в реальном времени
|
||||
- 🔄 Автоматическая синхронизация данных и реконнект при ошибках
|
||||
- 🌐 Управление сквадами с актуальным статусом и массовыми действиями
|
||||
- 🚧 Автоматический режим тех. работ с уведомлениями пользователей
|
||||
- 📋 Логи и диагностика, включая журнал сбоев и действий бота
|
||||
- 📦 Проверка обновлений проекта и уведомления о новых релизах
|
||||
🛟 **Поддержка и модерация**
|
||||
- 🎫 Центр тикетов с приоритетами, статусами и перепиской.
|
||||
- 🧑⚖️ Разграничение ролей (админы, модераторы), журнал аудита действий.
|
||||
- ⏱️ SLA таймеры, автоматические напоминания и уведомления в канал.
|
||||
- 🚫 Массовые блокировки нарушителей и автоматическое снятие блокировок.
|
||||
- 🧾 История диалогов, прикреплённые файлы и быстрые ответы.
|
||||
|
||||
📨 **Коммуникации и поддержка**
|
||||
- 📢 Рассылки по сегментам и мгновенные оповещения
|
||||
- 🔔 Автоуведомления о продлении, задолженностях и автоплатежах
|
||||
- 💬 Система поддержки с HTML разметкой и история обращений
|
||||
- 📝 Настройка правил сервиса, FAQ и приветственных экранов
|
||||
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
|
||||
🔔 **Уведомления и коммуникации**
|
||||
- 📢 Топики для событий (покупки, триалы, техработы, ошибки интеграций).
|
||||
- 🔔 Настройка пользовательских и админских уведомлений, расписаний и каналов.
|
||||
- 📨 Шаблоны правил, оферты, FAQ, приветствий и страницы поддержки.
|
||||
- 💬 Автоматические сообщения о задолженностях, автоплатежах и продлениях.
|
||||
|
||||
📨 **Уведомления в закрытый канал**
|
||||
- 🎯 Активация триала, продления и конверсии в платную подписку
|
||||
- 💎 Покупка подписки и пополнение баланса
|
||||
- 🔄 Изменения параметров подписки пользователем
|
||||
- 🚧 Потеря соединения с Remnawave и включение техработ
|
||||
- 🗄️ Авто- и ручные бекапы/восстановления с отправкой файлов
|
||||
- ♻️ Выхлоп новых версий бота и результаты автоплатежей
|
||||
🧰 **Обслуживание и DevOps**
|
||||
- 🛠️ `install_bot.sh`: управление контейнерами, логами, обновлениями, прокси и резервными копиями.
|
||||
- 🚧 Ручной и авто-режим техработ с кастомными сообщениями.
|
||||
- 🗒️ Просмотр системных логов, health-check, мониторинг Remnawave и серверов.
|
||||
- ♻️ Проверка обновлений репозитория, уведомления о новых версиях и плавный деплой.
|
||||
|
||||
🗄️ **Бекапы и восстановление**
|
||||
- Ручной запуск бекапа с выбором содержимого (включая логи)
|
||||
- Автоматические бекапы по расписанию и управление retention-политикой
|
||||
- Восстановление из бекапа без остановки сервиса
|
||||
- Отправка копии бекапа в выделенный чат/топик и ведение метаданных
|
||||
- 🗓️ Планировщик авто-бекапов, гибкая политика хранения и компрессия.
|
||||
- 📦 Ручные бекапы с выбором содержимого (БД, конфиги, логи).
|
||||
- 📤 Отправка архивов в выделенный чат/топик, журнал попыток и статусов.
|
||||
- 🔁 Восстановление из бекапа без остановки бота, проверка целостности.
|
||||
|
||||
💳 **Автоплатежи и биллинг**
|
||||
- Настройки автоплатежа на уровне пользователя (вкл/выкл, дни до списания)
|
||||
- Принудительная проверка готовности автосписаний
|
||||
- Уведомления об успешных и неудачных автоплатежах с подсказками действий
|
||||
- Настройка минимального баланса и логирование всех списаний
|
||||
💳 **Биллинг и автоплатежи**
|
||||
- ⚙️ Глобальные настройки автосписаний, минимального баланса и напоминаний.
|
||||
- 🧪 Тестовые платежи для каждого провайдера прямо из админки.
|
||||
- 🪝 Управление вебхуками Tribute, YooKassa, CryptoBot, MulenPay и PayPalych.
|
||||
- 🧾 Экспорт транзакций, сверка с платёжками и интеграция с бухгалтерией.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1062,6 +1071,16 @@ WEBHOOK_PATH=/webhook
|
||||
- 📦 **Интеграция с Remnawave API** — автоматическое создание пользователей, управление сквадами и синхронизация подписок.
|
||||
- 🧾 **История операций** — хранение транзакций, уведомлений, рассылок, кампаний и бекапов для аудита.
|
||||
|
||||
### 🌐 Веб-API, мини-приложение и интеграции
|
||||
|
||||
- ⚙️ **FastAPI Web API** (`app/webapi`) с эндпоинтами для управления токенами, конфигурациями, рассылками, кампаниями, промогруппами, статистикой, подписками, транзакциями, тикетами и бекапами.
|
||||
- 🔑 **Управление API-ключами** — выпуск, отзыв, реактивация и аудит токенов прямо из админки.
|
||||
- 🛡️ **Гибкая авторизация** — API-key, Bearer, Basic Auth и cookie eGames для совместимости с внешними панелями.
|
||||
- 🛰️ **Mini App** — статический фронтенд в `miniapp/` и proxied эндпоинты `/miniapp/*` для отображения статуса подписки внутри Telegram.
|
||||
- 🧭 **App Config** — централизованная раздача ссылок на клиенты и инструкции (`app-config.json`, `miniapp/app-config.json`).
|
||||
- 🪝 **Платёжные вебхуки** — встроенные Flask-серверы для Tribute, YooKassa, CryptoBot, MulenPay и PayPalych (Pal24) с health-check и логированием.
|
||||
- 📡 **Мониторинг серверов** — REST-эндпоинты для просмотра нод Remnawave, статистики нагрузки и управления сквадами из внешних систем.
|
||||
|
||||
### 🧭 Навигация по проекту
|
||||
|
||||
- `app/bot.py` — точка входа бота и регистрация middlewares/handlers.
|
||||
@@ -1347,121 +1366,6 @@ ADMIN_NOTIFICATIONS_TOPIC_ID=123 # ID топика (опционал
|
||||
- **🔗 SQLAlchemy ORM** - безопасная работа с БД
|
||||
- **🚀 aiogram 3** - современная Telegram Bot API
|
||||
|
||||
### 📁 Структура проекта
|
||||
|
||||
```
|
||||
bedolaga_bot/
|
||||
├── 🎯 main.py # Точка входа
|
||||
├── 📦 requirements.txt # Зависимости
|
||||
├── ⚙️ .env.example # Конфиг
|
||||
├── ⚙️ app-config.json # Информация для гайда подключения
|
||||
│
|
||||
├── 📱 app/
|
||||
│ ├── 🤖 bot.py # Инициализация бота
|
||||
│ ├── ⚙️ config.py # Настройки
|
||||
│ ├── 🎛️ states.py # FSM состояния
|
||||
│ │
|
||||
│ ├── 🎮 handlers/ # Обработчики событий
|
||||
│ │ ├── 🏠 start.py # Регистрация и старт
|
||||
│ │ ├── 🛒 subscription.py # Подписки
|
||||
│ │ ├── 💰 balance.py # Баланс и платежи
|
||||
│ │ ├── 🎁 promocode.py # Промокоды
|
||||
│ │ ├── 👥 referral.py # Реферальная система
|
||||
│ │ ├── 🌟 stars_payment.py # Start платежи
|
||||
│ │ ├── common.py
|
||||
│ │ ├── 💬 support.py # Техподдержка
|
||||
│ │ └── 👑 admin/ # Админ панель
|
||||
│ │ ├── 📊 statistics.py # Статистика
|
||||
│ │ ├── 🗄️ backup.py # Бекапы
|
||||
│ │ ├── 👥 users.py # Управление юзерами
|
||||
│ │ ├── 🎫 promocodes.py # Управление промокодами
|
||||
│ │ ├── 🚧 maintenance.py # Тех работы
|
||||
│ │ ├── 📨 messages.py # Рассылки
|
||||
│ │ ├── 📨 user_messages.py # Рандомные сообщения в меню
|
||||
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
|
||||
│ │ ├── ⚙️ main.py # Админское меню
|
||||
│ │ ├── 📖 rules.py # Правила
|
||||
│ │ ├── 🙋 referrals.py # Правила
|
||||
│ │ ├── 🌎 servers.py # Сервера
|
||||
│ │ ├── 📱 subscriptions.py # Подписки
|
||||
│ │ ├── 🔍 monitoring.py # Мониторинг
|
||||
│ │ └── 🔗 remnawave.py # Система Remnawave
|
||||
│ │
|
||||
│ ├── 🗄️ database/ # База данных
|
||||
│ │ ├── 📊 models.py # Модели SQLAlchemy
|
||||
│ │ ├── 🔗 database.py # Подключение к БД
|
||||
│ │ ├── 🔄 universal_migration.py # Миграции
|
||||
│ │ └── 📝 crud/ # CRUD операции
|
||||
│ │ ├── 👤 user.py # Операции с пользователями
|
||||
│ │ ├── 📋 subscription.py # Операции с подписками
|
||||
│ │ ├── 💰 transaction.py # Операции с транзакциями
|
||||
│ │ ├── 📜 rules.py # Правила сервиса
|
||||
│ │ ├── 📜 subscription_conversion.py # Правила сервиса
|
||||
│ │ ├── 💳 yookassa.py # YooKassa операции
|
||||
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
|
||||
│ │ ├── 💳 cryptobot.py # CryptoBot операции
|
||||
│ │ ├── 🌐 server_squad.py # Серверы и сквады
|
||||
│ │ ├── 🎁 promocode.py # Промокоды
|
||||
│ │ └── 👥 referral.py # Партнерка
|
||||
│ │
|
||||
│ ├── 🔧 services/ # Бизнес-логика
|
||||
│ │ ├── 👤 user_service.py # Сервис пользователей
|
||||
│ │ ├── 📋 subscription_service.py # Сервис подписок
|
||||
│ │ ├── 💰 payment_service.py # Платежи
|
||||
│ │ ├── 🗄️ backup_service.py # Бекапы
|
||||
│ │ ├── 🎁 promocode_service.py # Промокоды
|
||||
│ │ ├── 🚧 maintenance_service.py # Промокоды
|
||||
│ │ ├── 👥 referral_service.py # Партнерка
|
||||
│ │ ├── 💬 admin_notification_service.py # Уведомления для администраторов в чаты
|
||||
│ │ ├── 🔍 monitoring_service.py # Мониторинг
|
||||
│ │ ├── ♻️ version_service.py # Проверка версий бота
|
||||
│ │ ├── 🎖️ tribute_service.py # Tribute платежи
|
||||
│ │ ├── 💳 yookassa_service.py # YooKassa платежи
|
||||
│ │ └── 🌐 remnawave_service.py # Интеграция с Remnawave
|
||||
│ │
|
||||
│ ├── 🛠️ utils/ # Утилиты
|
||||
│ │ ├── 🎨 decorators.py # Декораторы
|
||||
│ │ ├── 📝 formatters.py # Форматирование данных
|
||||
│ │ ├── ✅ validators.py # Валидация
|
||||
│ │ ├── ✅ subscription_utils.py # Проверка подписок
|
||||
│ │ ├── 📄 pagination.py # Пагинация
|
||||
│ │ ├── 📄 pricing_utils.py # Цены
|
||||
│ │ ├── 📄 message_patch.py
|
||||
│ │ ├── 📄 photo_message.py
|
||||
│ │ ├── ‼️ global_error.py # Обработка ошибок
|
||||
│ │ ├── 👤 user_utils.py # Утилиты для пользователей
|
||||
│ │ ├── 🫰 currency_converter.py # Курсы для CryptoBota
|
||||
│ │ └── ⚡ cache.py # Кеширование
|
||||
│ │
|
||||
│ ├── 🛡️ middlewares/ # Middleware
|
||||
│ │ ├── 🔐 auth.py # Авторизация
|
||||
│ │ ├── 📋 logging.py # Логирование
|
||||
│ │ ├── 🚧 maintenance.py # тех работы
|
||||
│ │ ├── 🔐 subscription_checker.py # тех работы
|
||||
│ │ ├── 🔐 channel_checker.py
|
||||
│ │ └── ⏱️ throttling.py # Ограничение запросов
|
||||
│ │
|
||||
│ ├── 🌐 localization/ # Локализация
|
||||
│ │ ├── 📝 texts.py # Тексты интерфейса
|
||||
│ │ └── 🗣️ languages/
|
||||
│ │
|
||||
│ ├── ⌨️ keyboards/ # Клавиатуры
|
||||
│ │ ├── 🔗 inline.py # Inline клавиатуры
|
||||
│ │ ├── 💬 reply.py # Reply клавиатуры
|
||||
│ │ └── 👑 admin.py # Админские клавиатуры
|
||||
│ │
|
||||
│ └── 🔌 external/ # Внешние API
|
||||
│ ├── 🌐 remnawave_api.py # Remnawave API
|
||||
│ ├── ⭐ telegram_stars.py # Telegram Stars
|
||||
│ ├── 💳 yookassa_webhook.py # YooKassa webhook
|
||||
│ ├── 🌐 webhook_server.py # Webhook сервер
|
||||
│ ├── 💳 cryptobot.py # CryptoBot Api
|
||||
│ └── 🎖️ tribute.py # Tribute платежи
|
||||
│
|
||||
├── 🔄 migrations/ # Миграции БД
|
||||
└── 📋 logs/ # Логи системы
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Устранение неполадок
|
||||
@@ -1705,62 +1609,146 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
|
||||
<tr>
|
||||
<td>🥇</td>
|
||||
<td><strong>@SmartSystemCompany</strong></td>
|
||||
<td>₽8,500</td>
|
||||
<td>За щедрую поддержку и вклад в развитие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥈</td>
|
||||
<td><strong>@pilot_737800</strong></td>
|
||||
<td>₽4,750</td>
|
||||
<td>₽7,750</td>
|
||||
<td>За веру в проект с самого начала</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥈</td>
|
||||
<td><strong>@Legacyyy777</strong></td>
|
||||
<td>₽2,600</td>
|
||||
<td>За ценные предложения по улучшению</td>
|
||||
<td><strong>@psych0O</strong></td>
|
||||
<td>$60</td>
|
||||
<td>За щедрую поддержку и вклад в развитие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥉</td>
|
||||
<td><strong>Илья (@ispanec_nn)</strong></td>
|
||||
<td>$30</td>
|
||||
<td>За активное тестирование и фидбек</td>
|
||||
<td><strong>@Vldmrmtn</strong></td>
|
||||
<td>₽5,000</td>
|
||||
<td>За значительную поддержку проекта</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td><strong>@k0tbtc</strong></td>
|
||||
<td>₽3,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td><strong>@Legacyyy777</strong></td>
|
||||
<td>₽2,900</td>
|
||||
<td>За ценные предложения по улучшению</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td><strong>@sklvg</strong></td>
|
||||
<td>₽3,000</td>
|
||||
<td>За международную поддержку</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>7</td>
|
||||
<td><strong>@bolgov0zero</strong></td>
|
||||
<td>₽2,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>8</td>
|
||||
<td><strong>@i_burjuy</strong></td>
|
||||
<td>₽1,300</td>
|
||||
<td>За активное участие в проекте</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>9</td>
|
||||
<td><strong>@kr33njee</strong></td>
|
||||
<td>₽1,550</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>10</td>
|
||||
<td><strong>@Nav1_0</strong></td>
|
||||
<td>₽2,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>11</td>
|
||||
<td><strong>@vitos_7771997</strong></td>
|
||||
<td>₽1,500</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>12</td>
|
||||
<td><strong>@haxonate</strong></td>
|
||||
<td>₽1,500</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>13</td>
|
||||
<td><strong>@fso404</strong></td>
|
||||
<td>₽1,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td>14</td>
|
||||
<td><strong>@edward_forix</strong></td>
|
||||
<td>₽1,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td><strong>@Nav1_0</strong></td>
|
||||
<td>₽1,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
<td>15</td>
|
||||
<td><strong>@Илья (@ispanec_nn)</strong></td>
|
||||
<td>$30</td>
|
||||
<td>За активное тестирование и фидбек</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td><strong>@kr33njee</strong></td>
|
||||
<td>₽800</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
<td>16</td>
|
||||
<td><strong>@DonMatteoRU</strong></td>
|
||||
<td>₽500</td>
|
||||
<td>За участие в развитии</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>7</td>
|
||||
<td><strong>@SmartSystemCompany</strong></td>
|
||||
<td>₽600</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
<td>17</td>
|
||||
<td><strong>@pedzeo</strong></td>
|
||||
<td>₽500</td>
|
||||
<td>За участие в развитии</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>8</td>
|
||||
<td>18</td>
|
||||
<td><strong>@suzumuj</strong></td>
|
||||
<td>₽400</td>
|
||||
<td>За участие в развитии</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>19</td>
|
||||
<td><strong>@andrey_93rus</strong></td>
|
||||
<td>₽300</td>
|
||||
<td>За участие в развитии</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>20</td>
|
||||
<td><strong>@PhiLin58</strong></td>
|
||||
<td>₽300</td>
|
||||
<td>За участие в развитии</td>
|
||||
|
||||
@@ -33,6 +33,7 @@ from app.handlers.admin import (
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
promo_offers as admin_promo_offers,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates,
|
||||
backup as admin_backup,
|
||||
@@ -42,6 +43,9 @@ from app.handlers.admin import (
|
||||
reports as admin_reports,
|
||||
bot_configuration as admin_bot_configuration,
|
||||
pricing as admin_pricing,
|
||||
privacy_policy as admin_privacy_policy,
|
||||
public_offer as admin_public_offer,
|
||||
faq as admin_faq,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -137,6 +141,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
admin_updates.register_handlers(dp)
|
||||
@@ -147,6 +152,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_reports.register_handlers(dp)
|
||||
admin_bot_configuration.register_handlers(dp)
|
||||
admin_pricing.register_handlers(dp)
|
||||
admin_privacy_policy.register_handlers(dp)
|
||||
admin_public_offer.register_handlers(dp)
|
||||
admin_faq.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+90
-6
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -11,8 +13,9 @@ from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
|
||||
BOT_TOKEN: str
|
||||
BOT_USERNAME: Optional[str] = None
|
||||
ADMIN_IDS: str = ""
|
||||
SUPPORT_USERNAME: str = "@support"
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
@@ -130,8 +133,9 @@ class Settings(BaseSettings):
|
||||
REFERRED_USER_REWARD: int = 0
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
|
||||
DEFAULT_AUTOPAY_ENABLED: bool = False
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
|
||||
|
||||
MONITORING_INTERVAL: int = 60
|
||||
@@ -149,6 +153,7 @@ class Settings(BaseSettings):
|
||||
TRIBUTE_API_KEY: Optional[str] = None
|
||||
TRIBUTE_DONATE_LINK: Optional[str] = None
|
||||
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
|
||||
TRIBUTE_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
TRIBUTE_WEBHOOK_PORT: int = 8081
|
||||
|
||||
YOOKASSA_ENABLED: bool = False
|
||||
@@ -158,9 +163,10 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
|
||||
YOOKASSA_VAT_CODE: int = 1
|
||||
YOOKASSA_SBP_ENABLED: bool = False
|
||||
YOOKASSA_PAYMENT_MODE: str = "full_payment"
|
||||
YOOKASSA_PAYMENT_MODE: str = "full_payment"
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = "service"
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
@@ -274,6 +280,9 @@ class Settings(BaseSettings):
|
||||
BACKUP_SEND_CHAT_ID: Optional[str] = None
|
||||
BACKUP_SEND_TOPIC_ID: Optional[int] = None
|
||||
|
||||
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
|
||||
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
|
||||
|
||||
@field_validator('SERVER_STATUS_MODE', mode='before')
|
||||
@classmethod
|
||||
def normalize_server_status_mode(cls, value: Optional[str]) -> str:
|
||||
@@ -457,6 +466,15 @@ class Settings(BaseSettings):
|
||||
return [3, 1]
|
||||
except (ValueError, AttributeError):
|
||||
return [3, 1]
|
||||
|
||||
def is_autopay_enabled_by_default(self) -> bool:
|
||||
value = getattr(self, "DEFAULT_AUTOPAY_ENABLED", True)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
try:
|
||||
@@ -510,7 +528,14 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_trial_warning_hours(self) -> int:
|
||||
return self.TRIAL_WARNING_HOURS
|
||||
|
||||
|
||||
def get_bot_username(self) -> Optional[str]:
|
||||
username = getattr(self, "BOT_USERNAME", None)
|
||||
if not username:
|
||||
return None
|
||||
normalized = str(username).strip().lstrip("@")
|
||||
return normalized or None
|
||||
|
||||
def is_notifications_enabled(self) -> bool:
|
||||
return self.ENABLE_NOTIFICATIONS
|
||||
|
||||
@@ -554,6 +579,37 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_app_config_cache_ttl(self) -> int:
|
||||
return self.APP_CONFIG_CACHE_TTL
|
||||
|
||||
def build_external_admin_token(self, bot_username: str) -> str:
|
||||
"""Генерирует детерминированный и криптографически стойкий токен внешней админки."""
|
||||
normalized = (bot_username or "").strip().lstrip("@").lower()
|
||||
if not normalized:
|
||||
raise ValueError("Bot username is required to build external admin token")
|
||||
|
||||
secret = (self.BOT_TOKEN or "").strip()
|
||||
if not secret:
|
||||
raise ValueError("Bot token is required to build external admin token")
|
||||
|
||||
digest = hmac.new(
|
||||
key=secret.encode("utf-8"),
|
||||
msg=f"remnawave.external_admin::{normalized}".encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
return digest[:48]
|
||||
|
||||
def get_external_admin_token(self) -> Optional[str]:
|
||||
token = (self.EXTERNAL_ADMIN_TOKEN or "").strip()
|
||||
return token or None
|
||||
|
||||
def get_external_admin_bot_id(self) -> Optional[int]:
|
||||
try:
|
||||
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
|
||||
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
|
||||
logging.getLogger(__name__).warning(
|
||||
"Некорректный идентификатор бота для внешней админки: %s",
|
||||
self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
|
||||
)
|
||||
return None
|
||||
|
||||
def is_traffic_selectable(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
|
||||
@@ -789,14 +845,42 @@ class Settings(BaseSettings):
|
||||
return (self.BACKUP_SEND_ENABLED and
|
||||
self.get_backup_send_chat_id() is not None)
|
||||
|
||||
def get_referred_user_reward_kopeks(self) -> int:
|
||||
"""Return the referred user reward normalized to kopeks.
|
||||
|
||||
Historically the value was stored in kopeks, however some
|
||||
installations provide it in rubles. To keep backward compatibility we
|
||||
treat any value greater than or equal to one thousand as already being
|
||||
in kopeks (≥ 10 ₽). Smaller positive values are assumed to be provided
|
||||
in rubles and therefore converted to kopeks.
|
||||
"""
|
||||
|
||||
raw_value = getattr(self, "REFERRED_USER_REWARD", 0)
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
if value <= 0:
|
||||
return 0
|
||||
|
||||
if value >= 1000:
|
||||
return value
|
||||
|
||||
return value * 100
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
|
||||
|
||||
return {
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
|
||||
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
|
||||
"referred_user_reward": self.REFERRED_USER_REWARD
|
||||
"referred_user_reward": referred_reward_kopeks,
|
||||
"referred_user_reward_kopeks": referred_reward_kopeks,
|
||||
}
|
||||
|
||||
def is_referral_notifications_enabled(self) -> bool:
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.database.models import DiscountOffer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def upsert_discount_offer(
|
||||
db: AsyncSession,
|
||||
@@ -16,6 +23,8 @@ async def upsert_discount_offer(
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
effect_type: str = "percent_discount",
|
||||
extra_data: Optional[dict] = None,
|
||||
) -> DiscountOffer:
|
||||
"""Create or refresh a discount offer for a user."""
|
||||
|
||||
@@ -37,6 +46,8 @@ async def upsert_discount_offer(
|
||||
offer.bonus_amount_kopeks = bonus_amount_kopeks
|
||||
offer.expires_at = expires_at
|
||||
offer.subscription_id = subscription_id
|
||||
offer.effect_type = effect_type
|
||||
offer.extra_data = extra_data
|
||||
else:
|
||||
offer = DiscountOffer(
|
||||
user_id=user_id,
|
||||
@@ -46,6 +57,8 @@ async def upsert_discount_offer(
|
||||
bonus_amount_kopeks=bonus_amount_kopeks,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
effect_type=effect_type,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
@@ -56,16 +69,128 @@ async def upsert_discount_offer(
|
||||
|
||||
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(DiscountOffer.id == offer_id)
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.where(DiscountOffer.id == offer_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
|
||||
async def list_discount_offers(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
user_id: Optional[int] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> List[DiscountOffer]:
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(DiscountOffer.user_id == user_id)
|
||||
if notification_type:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(DiscountOffer.is_active == is_active)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def list_active_discount_offers_for_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> List[DiscountOffer]:
|
||||
"""Return active (not yet claimed) offers for a user."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.expires_at > now,
|
||||
)
|
||||
.order_by(DiscountOffer.expires_at.asc())
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_discount_offers(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> int:
|
||||
stmt = select(func.count(DiscountOffer.id))
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(DiscountOffer.user_id == user_id)
|
||||
if notification_type:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(DiscountOffer.is_active == is_active)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def mark_offer_claimed(
|
||||
db: AsyncSession,
|
||||
offer: DiscountOffer,
|
||||
*,
|
||||
details: Optional[dict] = None,
|
||||
) -> DiscountOffer:
|
||||
offer.claimed_at = datetime.utcnow()
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=offer.user_id,
|
||||
offer_id=offer.id,
|
||||
action="claimed",
|
||||
source=offer.notification_type,
|
||||
percent=offer.discount_percent,
|
||||
effect_type=offer.effect_type,
|
||||
details=details,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer claim log for offer %s: %s",
|
||||
offer.id,
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer claim log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
return offer
|
||||
|
||||
|
||||
@@ -82,9 +207,69 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
log_payloads = []
|
||||
for offer in offers:
|
||||
offer.is_active = False
|
||||
count += 1
|
||||
log_payloads.append(
|
||||
{
|
||||
"user_id": offer.user_id,
|
||||
"offer_id": offer.id,
|
||||
"source": offer.notification_type,
|
||||
"percent": offer.discount_percent,
|
||||
"effect_type": offer.effect_type,
|
||||
}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
for payload in log_payloads:
|
||||
if not payload.get("user_id"):
|
||||
continue
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=payload["user_id"],
|
||||
offer_id=payload["offer_id"],
|
||||
action="disabled",
|
||||
source=payload.get("source"),
|
||||
percent=payload.get("percent"),
|
||||
effect_type=payload.get("effect_type"),
|
||||
details={"reason": "offer_expired"},
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer disable log for offer %s: %s",
|
||||
payload.get("offer_id"),
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer disable log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
async def get_latest_claimed_offer_for_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[DiscountOffer]:
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.claimed_at.isnot(None),
|
||||
)
|
||||
.order_by(DiscountOffer.claimed_at.desc())
|
||||
)
|
||||
|
||||
if source:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == source)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
|
||||
result = await db.execute(
|
||||
select(FaqSetting).where(FaqSetting.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
|
||||
setting = await get_faq_setting(db, language)
|
||||
|
||||
if setting:
|
||||
setting.is_enabled = bool(enabled)
|
||||
setting.updated_at = datetime.utcnow()
|
||||
else:
|
||||
setting = FaqSetting(
|
||||
language=language,
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(setting)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус FAQ для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if setting.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return setting
|
||||
|
||||
|
||||
async def upsert_faq_setting(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
|
||||
return await set_faq_enabled(db, language, enabled)
|
||||
|
||||
|
||||
async def get_faq_pages(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> list[FaqPage]:
|
||||
query = select(FaqPage).where(FaqPage.language == language)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(FaqPage.is_active.is_(True))
|
||||
|
||||
query = query.order_by(FaqPage.display_order.asc(), FaqPage.id.asc())
|
||||
|
||||
result = await db.execute(query)
|
||||
pages = list(result.scalars().all())
|
||||
return pages
|
||||
|
||||
|
||||
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
|
||||
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_faq_page(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
language: str,
|
||||
title: str,
|
||||
content: str,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: bool = True,
|
||||
) -> FaqPage:
|
||||
if display_order is None:
|
||||
result = await db.execute(
|
||||
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
|
||||
)
|
||||
max_order = result.scalar() or 0
|
||||
display_order = max_order + 1
|
||||
|
||||
page = FaqPage(
|
||||
language=language,
|
||||
title=title,
|
||||
content=content,
|
||||
display_order=display_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
db.add(page)
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
async def update_faq_page(
|
||||
db: AsyncSession,
|
||||
page: FaqPage,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> FaqPage:
|
||||
if title is not None:
|
||||
page.title = title
|
||||
if content is not None:
|
||||
page.content = content
|
||||
if display_order is not None:
|
||||
page.display_order = display_order
|
||||
if is_active is not None:
|
||||
page.is_active = bool(is_active)
|
||||
|
||||
page.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info("✅ Страница FAQ %s обновлена", page.id)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
|
||||
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
|
||||
await db.commit()
|
||||
logger.info("🗑️ Страница FAQ %s удалена", page_id)
|
||||
|
||||
|
||||
async def bulk_update_order(
|
||||
db: AsyncSession,
|
||||
pages: Iterable[tuple[int, int]],
|
||||
) -> None:
|
||||
for page_id, order in pages:
|
||||
await db.execute(
|
||||
update(FaqPage)
|
||||
.where(FaqPage.id == page_id)
|
||||
.values(display_order=order, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
MainMenuButton,
|
||||
MainMenuButtonActionType,
|
||||
MainMenuButtonVisibility,
|
||||
)
|
||||
|
||||
|
||||
async def count_main_menu_buttons(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count()).select_from(MainMenuButton))
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def get_main_menu_buttons(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> list[MainMenuButton]:
|
||||
stmt = select(MainMenuButton).order_by(
|
||||
MainMenuButton.display_order.asc(),
|
||||
MainMenuButton.id.asc(),
|
||||
)
|
||||
|
||||
if offset is not None:
|
||||
stmt = stmt.offset(offset)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_main_menu_button_by_id(
|
||||
db: AsyncSession, button_id: int
|
||||
) -> MainMenuButton | None:
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).where(MainMenuButton.id == button_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_next_display_order(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.max(MainMenuButton.display_order)))
|
||||
current_max = result.scalar()
|
||||
return (int(current_max) if current_max is not None else 0) + 1
|
||||
|
||||
|
||||
def _enum_value(value, enum_cls):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, enum_cls):
|
||||
return value.value
|
||||
return str(value)
|
||||
|
||||
|
||||
async def create_main_menu_button(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
text: str,
|
||||
action_type: MainMenuButtonActionType | str,
|
||||
action_value: str,
|
||||
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
|
||||
is_active: bool = True,
|
||||
display_order: Optional[int] = None,
|
||||
) -> MainMenuButton:
|
||||
if display_order is None:
|
||||
display_order = await get_next_display_order(db)
|
||||
|
||||
button = MainMenuButton(
|
||||
text=text,
|
||||
action_type=_enum_value(action_type, MainMenuButtonActionType),
|
||||
action_value=action_value,
|
||||
visibility=_enum_value(visibility, MainMenuButtonVisibility)
|
||||
or MainMenuButtonVisibility.ALL.value,
|
||||
is_active=bool(is_active),
|
||||
display_order=int(display_order),
|
||||
)
|
||||
|
||||
db.add(button)
|
||||
await db.commit()
|
||||
await db.refresh(button)
|
||||
return button
|
||||
|
||||
|
||||
async def update_main_menu_button(
|
||||
db: AsyncSession,
|
||||
button: MainMenuButton,
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
action_type: MainMenuButtonActionType | str | None = None,
|
||||
action_value: Optional[str] = None,
|
||||
visibility: MainMenuButtonVisibility | str | None = None,
|
||||
is_active: Optional[bool] = None,
|
||||
display_order: Optional[int] = None,
|
||||
) -> MainMenuButton:
|
||||
if text is not None:
|
||||
button.text = text
|
||||
if action_type is not None:
|
||||
button.action_type = _enum_value(action_type, MainMenuButtonActionType)
|
||||
if action_value is not None:
|
||||
button.action_value = action_value
|
||||
if visibility is not None:
|
||||
button.visibility = _enum_value(visibility, MainMenuButtonVisibility)
|
||||
if is_active is not None:
|
||||
button.is_active = bool(is_active)
|
||||
if display_order is not None:
|
||||
button.display_order = int(display_order)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(button)
|
||||
return button
|
||||
|
||||
|
||||
async def delete_main_menu_button(db: AsyncSession, button: MainMenuButton) -> None:
|
||||
await db.delete(button)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def reorder_main_menu_buttons(
|
||||
db: AsyncSession,
|
||||
ordered_ids: Sequence[int],
|
||||
) -> None:
|
||||
if not ordered_ids:
|
||||
return
|
||||
|
||||
order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)}
|
||||
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))
|
||||
)
|
||||
buttons = result.scalars().all()
|
||||
|
||||
for button in buttons:
|
||||
desired_order = order_map.get(button.id)
|
||||
if desired_order is not None:
|
||||
button.display_order = desired_order
|
||||
|
||||
await db.commit()
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PrivacyPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
|
||||
result = await db.execute(
|
||||
select(PrivacyPolicy).where(PrivacyPolicy.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_privacy_policy(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
*,
|
||||
enable_if_new: bool = True,
|
||||
) -> PrivacyPolicy:
|
||||
policy = await get_privacy_policy(db, language)
|
||||
|
||||
if policy:
|
||||
policy.content = content or ""
|
||||
policy.updated_at = datetime.utcnow()
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
content=content or "",
|
||||
is_enabled=True if enable_if_new else False,
|
||||
)
|
||||
db.add(policy)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
logger.info(
|
||||
"✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
|
||||
language,
|
||||
policy.id,
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
async def set_privacy_policy_enabled(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PrivacyPolicy:
|
||||
policy = await get_privacy_policy(db, language)
|
||||
|
||||
if policy:
|
||||
policy.is_enabled = bool(enabled)
|
||||
policy.updated_at = datetime.utcnow()
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
content="",
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(policy)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус политики конфиденциальности для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if policy.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return policy
|
||||
@@ -50,6 +50,25 @@ async def get_promo_groups_with_counts(
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
.order_by(PromoGroup.auto_assign_total_spent_kopeks, PromoGroup.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def has_auto_assign_promo_groups(db: AsyncSession) -> bool:
|
||||
result = await db.execute(
|
||||
select(func.count(PromoGroup.id))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
)
|
||||
return bool(result.scalar_one())
|
||||
|
||||
|
||||
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
|
||||
return await db.get(PromoGroup, group_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoOfferLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def log_promo_offer_action(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int],
|
||||
offer_id: Optional[int],
|
||||
action: str,
|
||||
source: Optional[str] = None,
|
||||
percent: Optional[int] = None,
|
||||
effect_type: Optional[str] = None,
|
||||
details: Optional[Dict[str, object]] = None,
|
||||
commit: bool = True,
|
||||
) -> PromoOfferLog:
|
||||
"""Persist a promo offer log entry."""
|
||||
|
||||
entry = PromoOfferLog(
|
||||
user_id=user_id,
|
||||
offer_id=offer_id,
|
||||
action=action,
|
||||
source=source,
|
||||
percent=percent,
|
||||
effect_type=effect_type,
|
||||
details=(details or {}).copy(),
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
if commit:
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
except Exception:
|
||||
logger.exception("Failed to commit promo offer log entry")
|
||||
raise
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def list_promo_offer_logs(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
offer_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> Tuple[List[PromoOfferLog], int]:
|
||||
stmt = (
|
||||
select(PromoOfferLog)
|
||||
.options(
|
||||
selectinload(PromoOfferLog.user),
|
||||
selectinload(PromoOfferLog.offer),
|
||||
)
|
||||
.order_by(PromoOfferLog.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(PromoOfferLog.user_id == user_id)
|
||||
if offer_id is not None:
|
||||
stmt = stmt.where(PromoOfferLog.offer_id == offer_id)
|
||||
if action:
|
||||
stmt = stmt.where(PromoOfferLog.action == action)
|
||||
if source:
|
||||
stmt = stmt.where(PromoOfferLog.source == source)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
|
||||
count_stmt = select(func.count(PromoOfferLog.id))
|
||||
if user_id is not None:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.user_id == user_id)
|
||||
if offer_id is not None:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.offer_id == offer_id)
|
||||
if action:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.action == action)
|
||||
if source:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.source == source)
|
||||
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
return logs, total
|
||||
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PromoOfferTemplate
|
||||
|
||||
|
||||
UPDATED_TEMPLATE_MESSAGES = {
|
||||
"extend_discount": (
|
||||
"💎 Экономия {discount_percent}% при продлении\n\n"
|
||||
"Скидка суммируется с промогруппой и действует один раз.\n"
|
||||
"Срок действия предложения — {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
"purchase_discount": (
|
||||
"🎯 Вернитесь со скидкой {discount_percent}%\n\n"
|
||||
"Скидка суммируется с промогруппой и действует один раз.\n"
|
||||
"Предложение действует {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
LEGACY_TEMPLATE_MESSAGES = {
|
||||
"extend_discount": (
|
||||
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
|
||||
"Активируйте предложение и получите дополнительную скидку на оплату продления. "
|
||||
"Она суммируется с вашими промогрупповыми скидками и действует один раз.\n"
|
||||
"Срок действия предложения — {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
"purchase_discount": (
|
||||
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n"
|
||||
"После активации мы применим дополнительную скидку к вашей следующей оплате подписки. "
|
||||
"Скидка суммируется с промогруппой и действует один раз.\n"
|
||||
"Предложение действует {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_TEMPLATES: tuple[dict, ...] = (
|
||||
{
|
||||
"offer_type": "test_access",
|
||||
"name": "Тестовые сервера",
|
||||
"message_text": (
|
||||
"🔥 <b>Испытайте новые сервера</b>\n\n"
|
||||
"Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n"
|
||||
"Предложение действительно {valid_hours} ч."
|
||||
),
|
||||
"button_text": "🚀 Попробовать серверы",
|
||||
"valid_hours": 24,
|
||||
"discount_percent": 0,
|
||||
"bonus_amount_kopeks": 0,
|
||||
"active_discount_hours": None,
|
||||
"test_duration_hours": 24,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
{
|
||||
"offer_type": "extend_discount",
|
||||
"name": "Скидка на продление",
|
||||
"message_text": UPDATED_TEMPLATE_MESSAGES["extend_discount"],
|
||||
"button_text": "🎁 Получить скидку",
|
||||
"valid_hours": 24,
|
||||
"discount_percent": 20,
|
||||
"bonus_amount_kopeks": 0,
|
||||
"active_discount_hours": 24,
|
||||
"test_duration_hours": None,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
{
|
||||
"offer_type": "purchase_discount",
|
||||
"name": "Скидка на покупку",
|
||||
"message_text": UPDATED_TEMPLATE_MESSAGES["purchase_discount"],
|
||||
"button_text": "🎁 Забрать скидку",
|
||||
"valid_hours": 48,
|
||||
"discount_percent": 25,
|
||||
"bonus_amount_kopeks": 0,
|
||||
"active_discount_hours": 48,
|
||||
"test_duration_hours": None,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _format_template_fields(payload: dict) -> dict:
|
||||
data = dict(payload)
|
||||
data.setdefault("valid_hours", 24)
|
||||
data.setdefault("discount_percent", 0)
|
||||
data.setdefault("bonus_amount_kopeks", 0)
|
||||
data.setdefault("active_discount_hours", None)
|
||||
data.setdefault("test_duration_hours", None)
|
||||
data.setdefault("test_squad_uuids", [])
|
||||
return data
|
||||
|
||||
|
||||
async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]:
|
||||
templates: List[PromoOfferTemplate] = []
|
||||
|
||||
for template_data in DEFAULT_TEMPLATES:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"])
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data["offer_type"])
|
||||
legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data["offer_type"])
|
||||
should_update = False
|
||||
|
||||
if new_message and legacy_message and existing.message_text == legacy_message:
|
||||
should_update = True
|
||||
elif new_message and (
|
||||
"{bonus_amount" in existing.message_text or "Мы начислим" in existing.message_text
|
||||
):
|
||||
should_update = True
|
||||
|
||||
if should_update and new_message:
|
||||
existing.message_text = new_message
|
||||
existing.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
|
||||
target_active_hours = template_data.get("active_discount_hours")
|
||||
if (
|
||||
target_active_hours is not None
|
||||
and target_active_hours > 0
|
||||
and not existing.active_discount_hours
|
||||
):
|
||||
existing.active_discount_hours = target_active_hours
|
||||
existing.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
templates.append(existing)
|
||||
continue
|
||||
|
||||
payload = _format_template_fields(template_data)
|
||||
template = PromoOfferTemplate(
|
||||
name=payload["name"],
|
||||
offer_type=payload["offer_type"],
|
||||
message_text=payload["message_text"],
|
||||
button_text=payload["button_text"],
|
||||
valid_hours=payload["valid_hours"],
|
||||
discount_percent=payload["discount_percent"],
|
||||
bonus_amount_kopeks=payload["bonus_amount_kopeks"],
|
||||
active_discount_hours=payload["active_discount_hours"],
|
||||
test_duration_hours=payload["test_duration_hours"],
|
||||
test_squad_uuids=payload["test_squad_uuids"],
|
||||
is_active=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
templates.append(template)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_promo_offer_template(
|
||||
db: AsyncSession,
|
||||
template: PromoOfferTemplate,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
message_text: Optional[str] = None,
|
||||
button_text: Optional[str] = None,
|
||||
valid_hours: Optional[int] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
bonus_amount_kopeks: Optional[int] = None,
|
||||
active_discount_hours: Optional[int] = None,
|
||||
test_duration_hours: Optional[int] = None,
|
||||
test_squad_uuids: Optional[Iterable[str]] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> PromoOfferTemplate:
|
||||
if name is not None:
|
||||
template.name = name
|
||||
if message_text is not None:
|
||||
template.message_text = message_text
|
||||
if button_text is not None:
|
||||
template.button_text = button_text
|
||||
if valid_hours is not None:
|
||||
template.valid_hours = valid_hours
|
||||
if discount_percent is not None:
|
||||
template.discount_percent = discount_percent
|
||||
if bonus_amount_kopeks is not None:
|
||||
template.bonus_amount_kopeks = bonus_amount_kopeks
|
||||
if active_discount_hours is not None:
|
||||
template.active_discount_hours = active_discount_hours
|
||||
if test_duration_hours is not None:
|
||||
template.test_duration_hours = test_duration_hours
|
||||
if test_squad_uuids is not None:
|
||||
template.test_squad_uuids = list(test_squad_uuids)
|
||||
if is_active is not None:
|
||||
template.is_active = is_active
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PublicOffer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]:
|
||||
result = await db.execute(
|
||||
select(PublicOffer).where(PublicOffer.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_public_offer(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
*,
|
||||
enable_if_new: bool = True,
|
||||
) -> PublicOffer:
|
||||
offer = await get_public_offer(db, language)
|
||||
|
||||
if offer:
|
||||
offer.content = content or ""
|
||||
offer.updated_at = datetime.utcnow()
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
content=content or "",
|
||||
is_enabled=True if enable_if_new else False,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
logger.info(
|
||||
"✅ Публичная оферта для языка %s обновлена (ID: %s)",
|
||||
language,
|
||||
offer.id,
|
||||
)
|
||||
|
||||
return offer
|
||||
|
||||
|
||||
async def set_public_offer_enabled(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PublicOffer:
|
||||
offer = await get_public_offer(db, language)
|
||||
|
||||
if offer:
|
||||
offer.is_enabled = bool(enabled)
|
||||
offer.updated_at = datetime.utcnow()
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
content="",
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус публичной оферты для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if offer.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return offer
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
@@ -38,6 +39,7 @@ async def create_server_squad(
|
||||
description: str = None,
|
||||
max_users: int = None,
|
||||
is_available: bool = True,
|
||||
is_trial_eligible: bool = False,
|
||||
promo_group_ids: Optional[Iterable[int]] = None,
|
||||
) -> ServerSquad:
|
||||
|
||||
@@ -70,6 +72,7 @@ async def create_server_squad(
|
||||
description=description,
|
||||
max_users=max_users,
|
||||
is_available=is_available,
|
||||
is_trial_eligible=is_trial_eligible,
|
||||
allowed_promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
@@ -199,7 +202,7 @@ async def update_server_squad(
|
||||
|
||||
valid_fields = {
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order'
|
||||
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
|
||||
}
|
||||
|
||||
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
|
||||
@@ -393,8 +396,70 @@ async def get_server_connected_users(
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_trial_eligible_server_squads(
|
||||
db: AsyncSession,
|
||||
include_unavailable: bool = False,
|
||||
) -> List[ServerSquad]:
|
||||
|
||||
query = select(ServerSquad).where(ServerSquad.is_trial_eligible.is_(True))
|
||||
|
||||
result = await db.execute(query)
|
||||
squads = result.scalars().unique().all()
|
||||
|
||||
if include_unavailable:
|
||||
return squads
|
||||
|
||||
preferred_squads: List[ServerSquad] = []
|
||||
fallback_squads: List[ServerSquad] = []
|
||||
|
||||
for squad in squads:
|
||||
current_users = squad.current_users or 0
|
||||
is_full = squad.max_users is not None and current_users >= squad.max_users
|
||||
|
||||
if is_full:
|
||||
continue
|
||||
|
||||
if squad.is_available:
|
||||
preferred_squads.append(squad)
|
||||
else:
|
||||
fallback_squads.append(squad)
|
||||
|
||||
if preferred_squads:
|
||||
return preferred_squads
|
||||
|
||||
if fallback_squads:
|
||||
return fallback_squads
|
||||
|
||||
return squads
|
||||
|
||||
|
||||
async def choose_random_trial_server_squad(
|
||||
db: AsyncSession,
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
squads = await get_trial_eligible_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return None
|
||||
|
||||
return random.choice(squads)
|
||||
|
||||
|
||||
async def get_random_trial_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
|
||||
squad = await choose_random_trial_server_squad(db)
|
||||
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
|
||||
country_names = {
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from typing import Iterable, Optional, List, Tuple
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -47,10 +47,31 @@ async def create_trial_subscription(
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
|
||||
|
||||
if not squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
squad_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
|
||||
if squad_uuid:
|
||||
logger.debug(
|
||||
"Выбран сквад %s для триальной подписки пользователя %s",
|
||||
squad_uuid,
|
||||
user_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось получить сквад для триальной подписки пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
squad_uuid = settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -59,14 +80,43 @@ async def create_trial_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=[squad_uuid] if squad_uuid else []
|
||||
connected_squads=[squad_uuid] if squad_uuid else [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
|
||||
|
||||
if squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
add_user_to_servers,
|
||||
)
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, [squad_uuid])
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
"📈 Обновлен счетчик пользователей для триального сквада %s",
|
||||
squad_uuid,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)",
|
||||
squad_uuid,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s",
|
||||
squad_uuid,
|
||||
error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -74,9 +124,10 @@ async def create_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
@@ -89,7 +140,9 @@ async def create_paid_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or []
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
@@ -97,6 +150,36 @@ async def create_paid_subscription(
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
|
||||
|
||||
squad_uuids = list(connected_squads or [])
|
||||
if update_server_counters and squad_uuids:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
add_user_to_servers,
|
||||
)
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, squad_uuids)
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
"📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)",
|
||||
user_id,
|
||||
squad_uuids,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)",
|
||||
user_id,
|
||||
squad_uuids,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -110,7 +193,14 @@ async def extend_subscription(
|
||||
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
|
||||
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
if days < 0:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(
|
||||
"📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s",
|
||||
abs(days),
|
||||
subscription.end_date,
|
||||
)
|
||||
elif subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
|
||||
else:
|
||||
@@ -133,7 +223,7 @@ async def extend_subscription(
|
||||
if subscription.user:
|
||||
subscription.user.has_had_paid_subscription = True
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value and days > 0:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
|
||||
@@ -223,6 +313,64 @@ async def remove_subscription_squad(
|
||||
return subscription
|
||||
|
||||
|
||||
async def decrement_subscription_server_counts(
|
||||
db: AsyncSession,
|
||||
subscription: Optional[Subscription],
|
||||
*,
|
||||
subscription_servers: Optional[Iterable[SubscriptionServer]] = None,
|
||||
) -> None:
|
||||
"""Decrease server counters linked to the provided subscription."""
|
||||
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
server_ids: set[int] = set()
|
||||
|
||||
if subscription_servers is not None:
|
||||
for sub_server in subscription_servers:
|
||||
if sub_server and sub_server.server_squad_id is not None:
|
||||
server_ids.add(sub_server.server_squad_id)
|
||||
else:
|
||||
try:
|
||||
ids_from_links = await get_subscription_server_ids(db, subscription.id)
|
||||
server_ids.update(ids_from_links)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
connected_squads = list(subscription.connected_squads or [])
|
||||
if connected_squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
|
||||
squad_server_ids = await get_server_ids_by_uuids(db, connected_squads)
|
||||
server_ids.update(squad_server_ids)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
if not server_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import remove_user_from_servers
|
||||
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s",
|
||||
list(server_ids),
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
async def update_subscription_autopay(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
@@ -986,7 +1134,9 @@ async def create_subscription(
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = ""
|
||||
subscription_crypto_link: str = "",
|
||||
autopay_enabled: Optional[bool] = None,
|
||||
autopay_days_before: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
|
||||
if end_date is None:
|
||||
@@ -1006,7 +1156,17 @@ async def create_subscription(
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
autopay_enabled=(
|
||||
settings.is_autopay_enabled_by_default()
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
),
|
||||
autopay_days_before=(
|
||||
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
),
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
|
||||
+219
-2
@@ -12,6 +12,7 @@ from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
PaymentMethod,
|
||||
@@ -19,6 +20,8 @@ from app.database.models import (
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -272,6 +275,8 @@ async def subtract_user_balance(
|
||||
description: str,
|
||||
create_transaction: bool = False,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
*,
|
||||
consume_promo_offer: bool = False,
|
||||
) -> bool:
|
||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
@@ -279,15 +284,57 @@ async def subtract_user_balance(
|
||||
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||
logger.error(f" 📝 Описание: {description}")
|
||||
|
||||
log_context: Optional[Dict[str, object]] = None
|
||||
if consume_promo_offer:
|
||||
try:
|
||||
current_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
current_percent = 0
|
||||
|
||||
if current_percent > 0:
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
log_context = {
|
||||
"offer_id": None,
|
||||
"percent": current_percent,
|
||||
"source": source,
|
||||
"effect_type": None,
|
||||
"details": {
|
||||
"reason": "manual_charge",
|
||||
"description": description,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
},
|
||||
}
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception as lookup_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to fetch latest claimed promo offer for user %s: %s",
|
||||
user.id,
|
||||
lookup_error,
|
||||
)
|
||||
offer = None
|
||||
|
||||
if offer:
|
||||
log_context["offer_id"] = offer.id
|
||||
log_context["effect_type"] = offer.effect_type
|
||||
if not log_context["percent"] and offer.discount_percent:
|
||||
log_context["percent"] = offer.discount_percent
|
||||
|
||||
if user.balance_kopeks < amount_kopeks:
|
||||
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks -= amount_kopeks
|
||||
|
||||
if consume_promo_offer and getattr(user, "promo_offer_discount_percent", 0):
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -305,6 +352,32 @@ async def subtract_user_balance(
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
offer_id=log_context.get("offer_id"),
|
||||
action="consumed",
|
||||
source=log_context.get("source"),
|
||||
percent=log_context.get("percent"),
|
||||
effect_type=log_context.get("effect_type"),
|
||||
details=log_context.get("details"),
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer consumption log for user %s: %s",
|
||||
user.id,
|
||||
log_error,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer consumption log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
logger.error(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}")
|
||||
return True
|
||||
|
||||
@@ -314,6 +387,97 @@ async def subtract_user_balance(
|
||||
return False
|
||||
|
||||
|
||||
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.promo_offer_discount_percent > 0,
|
||||
User.promo_offer_discount_expires_at.isnot(None),
|
||||
User.promo_offer_discount_expires_at <= now,
|
||||
)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
if not users:
|
||||
return 0
|
||||
|
||||
log_payloads: List[Dict[str, object]] = []
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
percent = 0
|
||||
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
offer_id = None
|
||||
effect_type = None
|
||||
|
||||
if source:
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception as lookup_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s",
|
||||
user.id,
|
||||
lookup_error,
|
||||
)
|
||||
offer = None
|
||||
|
||||
if offer:
|
||||
offer_id = offer.id
|
||||
effect_type = offer.effect_type
|
||||
if not percent and offer.discount_percent:
|
||||
percent = offer.discount_percent
|
||||
|
||||
log_payloads.append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"offer_id": offer_id,
|
||||
"source": source,
|
||||
"percent": percent,
|
||||
"effect_type": effect_type,
|
||||
}
|
||||
)
|
||||
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
|
||||
for payload in log_payloads:
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
continue
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user_id,
|
||||
offer_id=payload.get("offer_id"),
|
||||
action="disabled",
|
||||
source=payload.get("source"),
|
||||
percent=payload.get("percent"),
|
||||
effect_type=payload.get("effect_type"),
|
||||
details={"reason": "offer_expired"},
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to log promo offer expiration for user %s: %s",
|
||||
user_id,
|
||||
log_error,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer expiration log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
return len(users)
|
||||
|
||||
|
||||
async def get_users_list(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
@@ -515,6 +679,59 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
|
||||
now = datetime.utcnow()
|
||||
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
|
||||
if segment == "no_subscription":
|
||||
query = (
|
||||
base_query.outerjoin(Subscription, Subscription.user_id == User.id)
|
||||
.where(Subscription.id.is_(None))
|
||||
)
|
||||
else:
|
||||
query = base_query.join(Subscription)
|
||||
|
||||
if segment == "paid_active":
|
||||
query = query.where(
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
elif segment == "paid_expired":
|
||||
query = query.where(
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
or_(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date <= now,
|
||||
),
|
||||
)
|
||||
elif segment == "trial_active":
|
||||
query = query.where(
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
elif segment == "trial_expired":
|
||||
query = query.where(
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
or_(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date <= now,
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.warning("Неизвестный сегмент для промо: %s", segment)
|
||||
return []
|
||||
|
||||
result = await db.execute(query.order_by(User.id))
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
|
||||
|
||||
|
||||
+166
-4
@@ -79,6 +79,17 @@ class PaymentMethod(Enum):
|
||||
PAL24 = "pal24"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class MainMenuButtonActionType(Enum):
|
||||
URL = "url"
|
||||
MINI_APP = "mini_app"
|
||||
|
||||
|
||||
class MainMenuButtonVisibility(Enum):
|
||||
ALL = "all"
|
||||
ADMINS = "admins"
|
||||
SUBSCRIBERS = "subscribers"
|
||||
|
||||
class YooKassaPayment(Base):
|
||||
__tablename__ = "yookassa_payments"
|
||||
|
||||
@@ -386,9 +397,13 @@ class User(Base):
|
||||
transactions = relationship("Transaction", back_populates="user")
|
||||
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
|
||||
discount_offers = relationship("DiscountOffer", back_populates="user")
|
||||
promo_offer_logs = relationship("PromoOfferLog", back_populates="user")
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
||||
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
|
||||
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
|
||||
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
promo_offer_discount_source = Column(String(100), nullable=True)
|
||||
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
|
||||
last_remnawave_sync = Column(DateTime, nullable=True)
|
||||
trojan_password = Column(String(255), nullable=True)
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
@@ -453,6 +468,7 @@ class Subscription(Base):
|
||||
|
||||
user = relationship("User", back_populates="subscription")
|
||||
discount_offers = relationship("DiscountOffer", back_populates="subscription")
|
||||
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
@@ -739,9 +755,9 @@ class Squad(Base):
|
||||
|
||||
class ServiceRule(Base):
|
||||
__tablename__ = "service_rules"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
|
||||
order = Column(Integer, default=0)
|
||||
title = Column(String(255), nullable=False)
|
||||
|
||||
@@ -755,9 +771,54 @@ class ServiceRule(Base):
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class PrivacyPolicy(Base):
|
||||
__tablename__ = "privacy_policies"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, unique=True)
|
||||
content = Column(Text, nullable=False)
|
||||
is_enabled = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class PublicOffer(Base):
|
||||
__tablename__ = "public_offers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, unique=True)
|
||||
content = Column(Text, nullable=False)
|
||||
is_enabled = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class FaqSetting(Base):
|
||||
__tablename__ = "faq_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, unique=True)
|
||||
is_enabled = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class FaqPage(Base):
|
||||
__tablename__ = "faq_pages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
language = Column(String(10), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
display_order = Column(Integer, default=0, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(255), unique=True, nullable=False)
|
||||
value = Column(Text, nullable=True)
|
||||
@@ -811,11 +872,73 @@ class DiscountOffer(Base):
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
claimed_at = Column(DateTime, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
effect_type = Column(String(50), nullable=False, default="percent_discount")
|
||||
extra_data = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", back_populates="discount_offers")
|
||||
subscription = relationship("Subscription", back_populates="discount_offers")
|
||||
logs = relationship("PromoOfferLog", back_populates="offer")
|
||||
|
||||
|
||||
class PromoOfferTemplate(Base):
|
||||
__tablename__ = "promo_offer_templates"
|
||||
__table_args__ = (
|
||||
Index("ix_promo_offer_templates_type", "offer_type"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
offer_type = Column(String(50), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
button_text = Column(String(255), nullable=False)
|
||||
valid_hours = Column(Integer, nullable=False, default=24)
|
||||
discount_percent = Column(Integer, nullable=False, default=0)
|
||||
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
active_discount_hours = Column(Integer, nullable=True)
|
||||
test_duration_hours = Column(Integer, nullable=True)
|
||||
test_squad_uuids = Column(JSON, default=list)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User")
|
||||
|
||||
|
||||
class SubscriptionTemporaryAccess(Base):
|
||||
__tablename__ = "subscription_temporary_access"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
|
||||
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="CASCADE"), nullable=False)
|
||||
squad_uuid = Column(String(255), nullable=False)
|
||||
expires_at = Column(DateTime, nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
deactivated_at = Column(DateTime, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
was_already_connected = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
subscription = relationship("Subscription", back_populates="temporary_accesses")
|
||||
offer = relationship("DiscountOffer")
|
||||
|
||||
|
||||
class PromoOfferLog(Base):
|
||||
__tablename__ = "promo_offer_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
action = Column(String(50), nullable=False)
|
||||
source = Column(String(100), nullable=True)
|
||||
percent = Column(Integer, nullable=True)
|
||||
effect_type = Column(String(50), nullable=True)
|
||||
details = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="promo_offer_logs")
|
||||
offer = relationship("DiscountOffer", back_populates="logs")
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
@@ -851,6 +974,7 @@ class ServerSquad(Base):
|
||||
country_code = Column(String(5), nullable=True)
|
||||
|
||||
is_available = Column(Boolean, default=True)
|
||||
is_trial_eligible = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
price_kopeks = Column(Integer, default=0)
|
||||
|
||||
@@ -1140,4 +1264,42 @@ class WebApiToken(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "revoked"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
|
||||
|
||||
class MainMenuButton(Base):
|
||||
__tablename__ = "main_menu_buttons"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
text = Column(String(64), nullable=False)
|
||||
action_type = Column(String(20), nullable=False)
|
||||
action_value = Column(Text, nullable=False)
|
||||
visibility = Column(String(20), nullable=False, default=MainMenuButtonVisibility.ALL.value)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_main_menu_buttons_order", "display_order", "id"),
|
||||
)
|
||||
|
||||
@property
|
||||
def action_type_enum(self) -> MainMenuButtonActionType:
|
||||
try:
|
||||
return MainMenuButtonActionType(self.action_type)
|
||||
except ValueError:
|
||||
return MainMenuButtonActionType.URL
|
||||
|
||||
@property
|
||||
def visibility_enum(self) -> MainMenuButtonVisibility:
|
||||
try:
|
||||
return MainMenuButtonVisibility(self.visibility)
|
||||
except ValueError:
|
||||
return MainMenuButtonVisibility.ALL
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<MainMenuButton id={self.id} text='{self.text}' "
|
||||
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
|
||||
)
|
||||
|
||||
@@ -728,6 +728,8 @@ async def create_discount_offers_table():
|
||||
expires_at DATETIME NOT NULL,
|
||||
claimed_at DATETIME NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount',
|
||||
extra_data TEXT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -751,6 +753,8 @@ async def create_discount_offers_table():
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
claimed_at TIMESTAMP NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount',
|
||||
extra_data JSON NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
@@ -772,6 +776,8 @@ async def create_discount_offers_table():
|
||||
expires_at DATETIME NOT NULL,
|
||||
claimed_at DATETIME NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount',
|
||||
extra_data JSON NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -793,6 +799,494 @@ async def create_discount_offers_table():
|
||||
logger.error(f"Ошибка создания таблицы discount_offers: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_discount_offer_columns():
|
||||
try:
|
||||
effect_exists = await check_column_exists('discount_offers', 'effect_type')
|
||||
extra_exists = await check_column_exists('discount_offers', 'extra_data')
|
||||
|
||||
if effect_exists and extra_exists:
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not effect_exists:
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'"
|
||||
))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'"
|
||||
))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
if not extra_exists:
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN extra_data TEXT NULL"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN extra_data JSON NULL"
|
||||
))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE discount_offers ADD COLUMN extra_data JSON NULL"
|
||||
))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Колонки effect_type и extra_data для discount_offers проверены")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления колонок discount_offers: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_user_promo_offer_discount_columns():
|
||||
try:
|
||||
percent_exists = await check_column_exists('users', 'promo_offer_discount_percent')
|
||||
source_exists = await check_column_exists('users', 'promo_offer_discount_source')
|
||||
expires_exists = await check_column_exists('users', 'promo_offer_discount_expires_at')
|
||||
|
||||
if percent_exists and source_exists and expires_exists:
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not percent_exists:
|
||||
column_def = 'INTEGER NOT NULL DEFAULT 0'
|
||||
if db_type == 'mysql':
|
||||
column_def = 'INT NOT NULL DEFAULT 0'
|
||||
await conn.execute(text(
|
||||
f"ALTER TABLE users ADD COLUMN promo_offer_discount_percent {column_def}"
|
||||
))
|
||||
|
||||
if not source_exists:
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'TEXT NULL'
|
||||
elif db_type == 'postgresql':
|
||||
column_def = 'VARCHAR(100) NULL'
|
||||
elif db_type == 'mysql':
|
||||
column_def = 'VARCHAR(100) NULL'
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
await conn.execute(text(
|
||||
f"ALTER TABLE users ADD COLUMN promo_offer_discount_source {column_def}"
|
||||
))
|
||||
|
||||
if not expires_exists:
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'DATETIME NULL'
|
||||
elif db_type == 'postgresql':
|
||||
column_def = 'TIMESTAMP NULL'
|
||||
elif db_type == 'mysql':
|
||||
column_def = 'DATETIME NULL'
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
await conn.execute(text(
|
||||
f"ALTER TABLE users ADD COLUMN promo_offer_discount_expires_at {column_def}"
|
||||
))
|
||||
|
||||
logger.info("✅ Колонки promo_offer_discount_* для users проверены")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления колонок promo_offer_discount_*: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_promo_offer_template_active_duration_column() -> bool:
|
||||
try:
|
||||
column_exists = await check_column_exists('promo_offer_templates', 'active_discount_hours')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not column_exists:
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'INTEGER NULL'
|
||||
elif db_type == 'postgresql':
|
||||
column_def = 'INTEGER NULL'
|
||||
elif db_type == 'mysql':
|
||||
column_def = 'INT NULL'
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
await conn.execute(text(
|
||||
f"ALTER TABLE promo_offer_templates ADD COLUMN active_discount_hours {column_def}"
|
||||
))
|
||||
|
||||
await conn.execute(text(
|
||||
"UPDATE promo_offer_templates "
|
||||
"SET active_discount_hours = valid_hours "
|
||||
"WHERE offer_type IN ('extend_discount', 'purchase_discount') "
|
||||
"AND (active_discount_hours IS NULL OR active_discount_hours <= 0)"
|
||||
))
|
||||
|
||||
logger.info("✅ Колонка active_discount_hours в promo_offer_templates актуальна")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления active_discount_hours в promo_offer_templates: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_discount_offer_effect_types():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"UPDATE discount_offers SET effect_type = 'percent_discount' "
|
||||
"WHERE effect_type = 'balance_bonus'"
|
||||
))
|
||||
logger.info("✅ Типы эффектов discount_offers обновлены на percent_discount")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления типов эффектов discount_offers: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def reset_discount_offer_bonuses():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"UPDATE discount_offers SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"UPDATE promo_offer_templates SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0"
|
||||
))
|
||||
logger.info("✅ Бонусы промо-предложений сброшены до нуля")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обнуления бонусов промо-предложений: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_promo_offer_templates_table():
|
||||
table_exists = await check_table_exists('promo_offer_templates')
|
||||
if table_exists:
|
||||
logger.info("Таблица promo_offer_templates уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE promo_offer_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
offer_type VARCHAR(50) NOT NULL,
|
||||
message_text TEXT NOT NULL,
|
||||
button_text VARCHAR(255) NOT NULL,
|
||||
valid_hours INTEGER NOT NULL DEFAULT 24,
|
||||
discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
active_discount_hours INTEGER NULL,
|
||||
test_duration_hours INTEGER NULL,
|
||||
test_squad_uuids TEXT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_by INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_promo_offer_templates_type ON promo_offer_templates(offer_type);
|
||||
"""
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS promo_offer_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
offer_type VARCHAR(50) NOT NULL,
|
||||
message_text TEXT NOT NULL,
|
||||
button_text VARCHAR(255) NOT NULL,
|
||||
valid_hours INTEGER NOT NULL DEFAULT 24,
|
||||
discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
active_discount_hours INTEGER NULL,
|
||||
test_duration_hours INTEGER NULL,
|
||||
test_squad_uuids JSON NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_promo_offer_templates_type ON promo_offer_templates(offer_type);
|
||||
"""
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS promo_offer_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
offer_type VARCHAR(50) NOT NULL,
|
||||
message_text TEXT NOT NULL,
|
||||
button_text VARCHAR(255) NOT NULL,
|
||||
valid_hours INT NOT NULL DEFAULT 24,
|
||||
discount_percent INT NOT NULL DEFAULT 0,
|
||||
bonus_amount_kopeks INT NOT NULL DEFAULT 0,
|
||||
active_discount_hours INT NULL,
|
||||
test_duration_hours INT NULL,
|
||||
test_squad_uuids JSON NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_promo_offer_templates_type ON promo_offer_templates(offer_type);
|
||||
"""
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
|
||||
logger.info("✅ Таблица promo_offer_templates успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы promo_offer_templates: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_main_menu_buttons_table() -> bool:
|
||||
table_exists = await check_table_exists('main_menu_buttons')
|
||||
if table_exists:
|
||||
logger.info("Таблица main_menu_buttons уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE main_menu_buttons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text VARCHAR(64) NOT NULL,
|
||||
action_type VARCHAR(20) NOT NULL,
|
||||
action_value TEXT NOT NULL,
|
||||
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
|
||||
"""
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS main_menu_buttons (
|
||||
id SERIAL PRIMARY KEY,
|
||||
text VARCHAR(64) NOT NULL,
|
||||
action_type VARCHAR(20) NOT NULL,
|
||||
action_value TEXT NOT NULL,
|
||||
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
|
||||
"""
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS main_menu_buttons (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
text VARCHAR(64) NOT NULL,
|
||||
action_type VARCHAR(20) NOT NULL,
|
||||
action_value TEXT NOT NULL,
|
||||
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
display_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для таблицы main_menu_buttons: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
|
||||
logger.info("✅ Таблица main_menu_buttons успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы main_menu_buttons: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_promo_offer_logs_table() -> bool:
|
||||
table_exists = await check_table_exists('promo_offer_logs')
|
||||
if table_exists:
|
||||
logger.info("Таблица promo_offer_logs уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
db_type = await get_database_type()
|
||||
async with engine.begin() as conn:
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS promo_offer_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
offer_id INTEGER NULL REFERENCES discount_offers(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
source VARCHAR(100) NULL,
|
||||
percent INTEGER NULL,
|
||||
effect_type VARCHAR(50) NULL,
|
||||
details JSON NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_user_id ON promo_offer_logs(user_id);
|
||||
"""))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS promo_offer_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
offer_id INTEGER REFERENCES discount_offers(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
source VARCHAR(100),
|
||||
percent INTEGER,
|
||||
effect_type VARCHAR(50),
|
||||
details JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_user_id ON promo_offer_logs(user_id);
|
||||
"""))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS promo_offer_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NULL,
|
||||
offer_id INT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
source VARCHAR(100) NULL,
|
||||
percent INT NULL,
|
||||
effect_type VARCHAR(50) NULL,
|
||||
details JSON NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_promo_offer_logs_users FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_promo_offer_logs_offers FOREIGN KEY (offer_id) REFERENCES discount_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC);
|
||||
CREATE INDEX ix_promo_offer_logs_user_id ON promo_offer_logs(user_id);
|
||||
"""))
|
||||
else:
|
||||
logger.warning("Неизвестный тип БД для создания promo_offer_logs: %s", db_type)
|
||||
return False
|
||||
|
||||
logger.info("✅ Таблица promo_offer_logs успешно создана")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы promo_offer_logs: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_subscription_temporary_access_table():
|
||||
table_exists = await check_table_exists('subscription_temporary_access')
|
||||
if table_exists:
|
||||
logger.info("Таблица subscription_temporary_access уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_temporary_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_id INTEGER NOT NULL,
|
||||
offer_id INTEGER NOT NULL,
|
||||
squad_uuid VARCHAR(255) NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deactivated_at DATETIME NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
was_already_connected BOOLEAN NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(offer_id) REFERENCES discount_offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX ix_temp_access_subscription ON subscription_temporary_access(subscription_id);
|
||||
CREATE INDEX ix_temp_access_offer ON subscription_temporary_access(offer_id);
|
||||
CREATE INDEX ix_temp_access_active ON subscription_temporary_access(is_active, expires_at);
|
||||
"""
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS subscription_temporary_access (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
offer_id INTEGER NOT NULL REFERENCES discount_offers(id) ON DELETE CASCADE,
|
||||
squad_uuid VARCHAR(255) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
deactivated_at TIMESTAMP NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
was_already_connected BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_temp_access_subscription ON subscription_temporary_access(subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_temp_access_offer ON subscription_temporary_access(offer_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_temp_access_active ON subscription_temporary_access(is_active, expires_at);
|
||||
"""
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE IF NOT EXISTS subscription_temporary_access (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
subscription_id INT NOT NULL,
|
||||
offer_id INT NOT NULL,
|
||||
squad_uuid VARCHAR(255) NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deactivated_at DATETIME NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
was_already_connected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(offer_id) REFERENCES discount_offers(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX ix_temp_access_subscription ON subscription_temporary_access(subscription_id);
|
||||
CREATE INDEX ix_temp_access_offer ON subscription_temporary_access(offer_id);
|
||||
CREATE INDEX ix_temp_access_active ON subscription_temporary_access(is_active, expires_at);
|
||||
"""
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
|
||||
logger.info("✅ Таблица subscription_temporary_access успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы subscription_temporary_access: {e}")
|
||||
return False
|
||||
|
||||
async def create_user_messages_table():
|
||||
table_exists = await check_table_exists('user_messages')
|
||||
if table_exists:
|
||||
@@ -1945,6 +2439,41 @@ async def ensure_server_promo_groups_setup() -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def add_server_trial_flag_column() -> bool:
|
||||
column_exists = await check_column_exists('server_squads', 'is_trial_eligible')
|
||||
if column_exists:
|
||||
logger.info("Колонка is_trial_eligible уже существует в server_squads")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'BOOLEAN NOT NULL DEFAULT 0'
|
||||
elif db_type == 'postgresql':
|
||||
column_def = 'BOOLEAN NOT NULL DEFAULT FALSE'
|
||||
else:
|
||||
column_def = 'BOOLEAN NOT NULL DEFAULT FALSE'
|
||||
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE server_squads ADD COLUMN is_trial_eligible {column_def}")
|
||||
)
|
||||
|
||||
if db_type == 'postgresql':
|
||||
await conn.execute(
|
||||
text("ALTER TABLE server_squads ALTER COLUMN is_trial_eligible SET DEFAULT FALSE")
|
||||
)
|
||||
|
||||
logger.info("✅ Добавлена колонка is_trial_eligible в server_squads")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления колонки is_trial_eligible: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_system_settings_table() -> bool:
|
||||
table_exists = await check_table_exists("system_settings")
|
||||
if table_exists:
|
||||
@@ -2078,6 +2607,226 @@ async def create_web_api_tokens_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_privacy_policies_table() -> bool:
|
||||
table_exists = await check_table_exists("privacy_policies")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица privacy_policies уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE privacy_policies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE privacy_policies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE privacy_policies (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица privacy_policies создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы privacy_policies: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_public_offers_table() -> bool:
|
||||
table_exists = await check_table_exists("public_offers")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица public_offers уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE public_offers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE public_offers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE public_offers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица public_offers создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы public_offers: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_faq_settings_table() -> bool:
|
||||
table_exists = await check_table_exists("faq_settings")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица faq_settings уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE faq_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE faq_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE faq_settings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL UNIQUE,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица faq_settings создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы faq_settings: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_faq_pages_table() -> bool:
|
||||
table_exists = await check_table_exists("faq_pages")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица faq_pages уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE faq_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
language VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_faq_pages_language ON faq_pages(language);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE faq_pages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_faq_pages_language ON faq_pages(language);
|
||||
CREATE INDEX idx_faq_pages_order ON faq_pages(language, display_order);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE faq_pages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
language VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
display_order INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
CREATE INDEX idx_faq_pages_language ON faq_pages(language);
|
||||
CREATE INDEX idx_faq_pages_order ON faq_pages(language, display_order);
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица faq_pages создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы faq_pages: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_default_web_api_token() -> bool:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
|
||||
if not default_token:
|
||||
@@ -2160,6 +2909,41 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===")
|
||||
trial_column_ready = await add_server_trial_flag_column()
|
||||
if trial_column_ready:
|
||||
logger.info("✅ Колонка is_trial_eligible готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с колонкой is_trial_eligible")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===")
|
||||
privacy_policies_ready = await create_privacy_policies_table()
|
||||
if privacy_policies_ready:
|
||||
logger.info("✅ Таблица privacy_policies готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей privacy_policies")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PUBLIC_OFFERS ===")
|
||||
public_offers_ready = await create_public_offers_table()
|
||||
if public_offers_ready:
|
||||
logger.info("✅ Таблица public_offers готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей public_offers")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_SETTINGS ===")
|
||||
faq_settings_ready = await create_faq_settings_table()
|
||||
if faq_settings_ready:
|
||||
logger.info("✅ Таблица faq_settings готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей faq_settings")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_PAGES ===")
|
||||
faq_pages_ready = await create_faq_pages_table()
|
||||
if faq_pages_ready:
|
||||
logger.info("✅ Таблица faq_pages готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей faq_pages")
|
||||
|
||||
logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===")
|
||||
default_token_ready = await ensure_default_web_api_token()
|
||||
if default_token_ready:
|
||||
@@ -2201,6 +2985,64 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей discount_offers")
|
||||
|
||||
discount_columns_ready = await ensure_discount_offer_columns()
|
||||
if discount_columns_ready:
|
||||
logger.info("✅ Колонки discount_offers в актуальном состоянии")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить колонки discount_offers")
|
||||
|
||||
user_discount_columns_ready = await ensure_user_promo_offer_discount_columns()
|
||||
if user_discount_columns_ready:
|
||||
logger.info("✅ Колонки пользовательских промо-скидок готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки")
|
||||
|
||||
effect_types_updated = await migrate_discount_offer_effect_types()
|
||||
if effect_types_updated:
|
||||
logger.info("✅ Типы эффектов промо-предложений обновлены")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить типы эффектов промо-предложений")
|
||||
|
||||
bonuses_reset = await reset_discount_offer_bonuses()
|
||||
if bonuses_reset:
|
||||
logger.info("✅ Бонусные начисления промо-предложений отключены")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обнулить бонусы промо-предложений")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_TEMPLATES ===")
|
||||
promo_templates_created = await create_promo_offer_templates_table()
|
||||
if promo_templates_created:
|
||||
logger.info("✅ Таблица promo_offer_templates готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей promo_offer_templates")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===")
|
||||
main_menu_buttons_created = await create_main_menu_buttons_table()
|
||||
if main_menu_buttons_created:
|
||||
logger.info("✅ Таблица main_menu_buttons готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей main_menu_buttons")
|
||||
|
||||
template_columns_ready = await ensure_promo_offer_template_active_duration_column()
|
||||
if template_columns_ready:
|
||||
logger.info("✅ Колонка active_discount_hours промо-предложений готова")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить колонку active_discount_hours промо-предложений")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_LOGS ===")
|
||||
promo_logs_created = await create_promo_offer_logs_table()
|
||||
if promo_logs_created:
|
||||
logger.info("✅ Таблица promo_offer_logs готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей promo_offer_logs")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_TEMPORARY_ACCESS ===")
|
||||
temp_access_created = await create_subscription_temporary_access_table()
|
||||
if temp_access_created:
|
||||
logger.info("✅ Таблица subscription_temporary_access готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей subscription_temporary_access")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===")
|
||||
user_messages_created = await create_user_messages_table()
|
||||
if user_messages_created:
|
||||
@@ -2395,13 +3237,26 @@ async def check_migration_status():
|
||||
"subscription_conversions_table": False,
|
||||
"promo_groups_table": False,
|
||||
"server_promo_groups_table": False,
|
||||
"server_squads_trial_column": False,
|
||||
"privacy_policies_table": False,
|
||||
"public_offers_table": False,
|
||||
"users_promo_group_column": False,
|
||||
"promo_groups_period_discounts_column": False,
|
||||
"promo_groups_auto_assign_column": False,
|
||||
"promo_groups_addon_discount_column": False,
|
||||
"users_auto_promo_group_assigned_column": False,
|
||||
"users_auto_promo_group_threshold_column": False,
|
||||
"users_promo_offer_discount_percent_column": False,
|
||||
"users_promo_offer_discount_source_column": False,
|
||||
"users_promo_offer_discount_expires_column": False,
|
||||
"subscription_crypto_link_column": False,
|
||||
"discount_offers_table": False,
|
||||
"discount_offers_effect_column": False,
|
||||
"discount_offers_extra_column": False,
|
||||
"promo_offer_templates_table": False,
|
||||
"promo_offer_templates_active_discount_column": False,
|
||||
"promo_offer_logs_table": False,
|
||||
"subscription_temporary_access_table": False,
|
||||
}
|
||||
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
@@ -2409,9 +3264,20 @@ async def check_migration_status():
|
||||
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
|
||||
status["user_messages_table"] = await check_table_exists('user_messages')
|
||||
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
|
||||
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
|
||||
status["public_offers_table"] = await check_table_exists('public_offers')
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
||||
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
|
||||
status["server_squads_trial_column"] = await check_column_exists('server_squads', 'is_trial_eligible')
|
||||
|
||||
status["discount_offers_table"] = await check_table_exists('discount_offers')
|
||||
status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type')
|
||||
status["discount_offers_extra_column"] = await check_column_exists('discount_offers', 'extra_data')
|
||||
status["promo_offer_templates_table"] = await check_table_exists('promo_offer_templates')
|
||||
status["promo_offer_templates_active_discount_column"] = await check_column_exists('promo_offer_templates', 'active_discount_hours')
|
||||
status["promo_offer_logs_table"] = await check_table_exists('promo_offer_logs')
|
||||
status["subscription_temporary_access_table"] = await check_table_exists('subscription_temporary_access')
|
||||
|
||||
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
|
||||
@@ -2420,6 +3286,9 @@ async def check_migration_status():
|
||||
status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons')
|
||||
status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned')
|
||||
status["users_auto_promo_group_threshold_column"] = await check_column_exists('users', 'auto_promo_group_threshold_kopeks')
|
||||
status["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent')
|
||||
status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source')
|
||||
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
|
||||
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
||||
|
||||
media_fields_exist = (
|
||||
@@ -2447,19 +3316,32 @@ async def check_migration_status():
|
||||
"cryptobot_table": "Таблица CryptoBot payments",
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"privacy_policies_table": "Таблица политик конфиденциальности",
|
||||
"public_offers_table": "Таблица публичных оферт",
|
||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок",
|
||||
"promo_groups_table": "Таблица промо-групп",
|
||||
"server_promo_groups_table": "Связи серверов и промогрупп",
|
||||
"server_squads_trial_column": "Колонка триального назначения у серверов",
|
||||
"users_promo_group_column": "Колонка promo_group_id у пользователей",
|
||||
"promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
|
||||
"promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп",
|
||||
"promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп",
|
||||
"users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей",
|
||||
"users_auto_promo_group_threshold_column": "Порог последней авто-промогруппы у пользователей",
|
||||
"users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей",
|
||||
"users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей",
|
||||
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
|
||||
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
|
||||
"discount_offers_table": "Таблица discount_offers",
|
||||
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
|
||||
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
|
||||
"promo_offer_templates_table": "Таблица promo_offer_templates",
|
||||
"promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates",
|
||||
"promo_offer_logs_table": "Таблица promo_offer_logs",
|
||||
"subscription_temporary_access_table": "Таблица subscription_temporary_access",
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
|
||||
Vendored
+40
-20
@@ -66,20 +66,37 @@ class WebhookServer:
|
||||
|
||||
self.site = web.TCPSite(
|
||||
self.runner,
|
||||
host='0.0.0.0',
|
||||
host=settings.TRIBUTE_WEBHOOK_HOST,
|
||||
port=settings.TRIBUTE_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
|
||||
await self.site.start()
|
||||
|
||||
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
|
||||
logger.info(
|
||||
"Webhook сервер запущен на %s:%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"Tribute webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.TRIBUTE_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(
|
||||
f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
|
||||
"Mulen Pay webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(
|
||||
"CryptoBot webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.CRYPTOBOT_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска webhook сервера: {e}")
|
||||
@@ -133,20 +150,23 @@ class WebhookServer:
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
success = await payment_service.process_mulenpay_callback(db, payload)
|
||||
if success:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
|
||||
finally:
|
||||
break
|
||||
# Получаем соединение с БД
|
||||
db_generator = get_db()
|
||||
db = await db_generator.__anext__()
|
||||
|
||||
# Если не удалось получить соединение с БД
|
||||
return web.json_response({"status": "error", "reason": "database_error"}, status=500)
|
||||
try:
|
||||
success = await payment_service.process_mulenpay_callback(db, payload)
|
||||
if success:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
|
||||
finally:
|
||||
try:
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
|
||||
|
||||
Vendored
+13
-4
@@ -218,15 +218,24 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host='0.0.0.0',
|
||||
runner,
|
||||
host=settings.YOOKASSA_WEBHOOK_HOST,
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
await site.start()
|
||||
|
||||
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
logger.info(
|
||||
"✅ YooKassa webhook сервер запущен на %s:%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"🎯 YooKassa webhook URL: http://%s:%s%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
|
||||
@@ -18,7 +18,10 @@ from app.config import settings
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.tribute_service import TributeService
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
from app.states import BotConfigStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.currency_converter import currency_converter
|
||||
@@ -107,6 +110,12 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"icon": "⚡",
|
||||
"categories": ("WEB_API", "WEBHOOK", "LOG", "DEBUG"),
|
||||
},
|
||||
"external_admin": {
|
||||
"title": "🛡️ Внешняя админка",
|
||||
"description": "Токен, по которому внешняя админка проверяет запросы.",
|
||||
"icon": "🛡️",
|
||||
"categories": ("EXTERNAL_ADMIN",),
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
|
||||
@@ -123,6 +132,7 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
|
||||
"server",
|
||||
"maintenance",
|
||||
"advanced",
|
||||
"external_admin",
|
||||
)
|
||||
|
||||
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
|
||||
@@ -269,7 +279,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
settings.REFERRAL_COMMISSION_PERCENT
|
||||
or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
|
||||
or settings.REFERRAL_INVITER_BONUS_KOPEKS
|
||||
or settings.REFERRED_USER_REWARD
|
||||
or settings.get_referred_user_reward_kopeks()
|
||||
)
|
||||
return ("🟢", "Программа активна") if active else ("⚪", "Бонусы не заданы")
|
||||
|
||||
@@ -687,6 +697,12 @@ async def apply_preset(
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, setting_key, value)
|
||||
applied.append(setting_key)
|
||||
except ReadOnlySettingError:
|
||||
logging.getLogger(__name__).info(
|
||||
"Пропускаем настройку %s из пресета %s: только для чтения",
|
||||
setting_key,
|
||||
preset_key,
|
||||
)
|
||||
except Exception as error:
|
||||
logging.getLogger(__name__).warning(
|
||||
"Не удалось применить пресет %s для %s: %s",
|
||||
@@ -854,8 +870,14 @@ async def handle_import_message(
|
||||
errors.append(f"{setting_key}: {error}")
|
||||
continue
|
||||
|
||||
await bot_configuration_service.set_value(db, setting_key, value_to_apply)
|
||||
applied.append(setting_key)
|
||||
if bot_configuration_service.is_read_only(setting_key):
|
||||
skipped.append(setting_key)
|
||||
continue
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, setting_key, value_to_apply)
|
||||
applied.append(setting_key)
|
||||
except ReadOnlySettingError:
|
||||
skipped.append(setting_key)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1361,9 +1383,10 @@ def _build_setting_keyboard(
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
rows: list[list[types.InlineKeyboardButton]] = []
|
||||
callback_token = bot_configuration_service.get_callback_token(key)
|
||||
is_read_only = bot_configuration_service.is_read_only(key)
|
||||
|
||||
choice_options = bot_configuration_service.get_choice_options(key)
|
||||
if choice_options:
|
||||
if choice_options and not is_read_only:
|
||||
current_value = bot_configuration_service.get_current_value(key)
|
||||
choice_buttons: list[types.InlineKeyboardButton] = []
|
||||
for option in choice_options:
|
||||
@@ -1385,7 +1408,7 @@ def _build_setting_keyboard(
|
||||
for chunk in _chunk(choice_buttons, 2):
|
||||
rows.append(list(chunk))
|
||||
|
||||
if definition.python_type is bool:
|
||||
if definition.python_type is bool and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🔁 Переключить",
|
||||
@@ -1395,16 +1418,17 @@ def _build_setting_keyboard(
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Изменить",
|
||||
callback_data=(
|
||||
f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}"
|
||||
),
|
||||
)
|
||||
])
|
||||
if not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Изменить",
|
||||
callback_data=(
|
||||
f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}"
|
||||
),
|
||||
)
|
||||
])
|
||||
|
||||
if bot_configuration_service.has_override(key):
|
||||
if bot_configuration_service.has_override(key) and not is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="♻️ Сбросить",
|
||||
@@ -1414,6 +1438,14 @@ def _build_setting_keyboard(
|
||||
)
|
||||
])
|
||||
|
||||
if is_read_only:
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🔒 Только для чтения",
|
||||
callback_data="botcfg_group:noop",
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
@@ -1438,6 +1470,11 @@ def _render_setting_text(key: str) -> str:
|
||||
f"📌 <b>Текущее:</b> {summary['current']}",
|
||||
f"📦 <b>По умолчанию:</b> {summary['original']}",
|
||||
f"✳️ <b>Переопределено:</b> {'Да' if summary['has_override'] else 'Нет'}",
|
||||
*(
|
||||
["🔒 <b>Режим:</b> Только для чтения (управляется автоматически)"]
|
||||
if summary.get("is_read_only")
|
||||
else []
|
||||
),
|
||||
"",
|
||||
f"📘 <b>Описание:</b> {guidance['description']}",
|
||||
f"📐 <b>Формат:</b> {guidance['format']}",
|
||||
@@ -2066,6 +2103,9 @@ async def start_edit_setting(
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
|
||||
summary = bot_configuration_service.get_setting_summary(key)
|
||||
@@ -2131,13 +2171,23 @@ async def handle_edit_setting(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await message.answer("⚠️ Эта настройка доступна только для чтения.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
value = bot_configuration_service.parse_user_value(key, message.text or "")
|
||||
except ValueError as error:
|
||||
await message.answer(f"⚠️ {error}")
|
||||
return
|
||||
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError:
|
||||
await message.answer("⚠️ Эта настройка доступна только для чтения.")
|
||||
await state.clear()
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
@@ -2172,13 +2222,23 @@ async def handle_direct_setting_input(
|
||||
if not key:
|
||||
return
|
||||
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await message.answer("⚠️ Эта настройка доступна только для чтения.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
value = bot_configuration_service.parse_user_value(key, message.text or "")
|
||||
except ValueError as error:
|
||||
await message.answer(f"⚠️ {error}")
|
||||
return
|
||||
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError:
|
||||
await message.answer("⚠️ Эта настройка доступна только для чтения.")
|
||||
await state.clear()
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
@@ -2220,7 +2280,14 @@ async def reset_setting(
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
try:
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
except ReadOnlySettingError:
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
@@ -2260,9 +2327,16 @@ async def toggle_setting(
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
current = bot_configuration_service.get_current_value(key)
|
||||
new_value = not bool(current)
|
||||
await bot_configuration_service.set_value(db, key, new_value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, new_value)
|
||||
except ReadOnlySettingError:
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
@@ -2304,6 +2378,9 @@ async def apply_setting_choice(
|
||||
except KeyError:
|
||||
await callback.answer("Эта настройка больше недоступна", show_alert=True)
|
||||
return
|
||||
if bot_configuration_service.is_read_only(key):
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
value = bot_configuration_service.resolve_choice_token(key, choice_token)
|
||||
@@ -2311,7 +2388,11 @@ async def apply_setting_choice(
|
||||
await callback.answer("Это значение больше недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError:
|
||||
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
text = _render_setting_text(key)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -226,18 +226,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
|
||||
elif notification_type == "expired_2d":
|
||||
percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
|
||||
(
|
||||
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
|
||||
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
|
||||
"Предложение действует до {expires_at}."
|
||||
"Активируйте предложение, чтобы получить дополнительную скидку. "
|
||||
"Она суммируется с вашей промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
bonus=settings.format_price(bonus_amount),
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
|
||||
trigger_days=3,
|
||||
)
|
||||
@@ -273,18 +271,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
|
||||
percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
|
||||
(
|
||||
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
|
||||
"Скидка действует до {expires_at}."
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
|
||||
"Она суммируется с промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
bonus=settings.format_price(bonus_amount),
|
||||
trigger_days=trigger_days,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
|
||||
+249
-32
@@ -21,6 +21,25 @@ logger = logging.getLogger(__name__)
|
||||
PriceItem = Tuple[str, str, int]
|
||||
|
||||
|
||||
TRAFFIC_PACKAGE_FIELDS: Tuple[Tuple[int, str], ...] = (
|
||||
(5, "PRICE_TRAFFIC_5GB"),
|
||||
(10, "PRICE_TRAFFIC_10GB"),
|
||||
(25, "PRICE_TRAFFIC_25GB"),
|
||||
(50, "PRICE_TRAFFIC_50GB"),
|
||||
(100, "PRICE_TRAFFIC_100GB"),
|
||||
(250, "PRICE_TRAFFIC_250GB"),
|
||||
(500, "PRICE_TRAFFIC_500GB"),
|
||||
(1000, "PRICE_TRAFFIC_1000GB"),
|
||||
(0, "PRICE_TRAFFIC_UNLIMITED"),
|
||||
)
|
||||
|
||||
TRAFFIC_PACKAGE_FIELD_MAP: Dict[int, str] = {gb: field for gb, field in TRAFFIC_PACKAGE_FIELDS}
|
||||
TRAFFIC_PACKAGE_ORDER: Tuple[int, ...] = tuple(gb for gb, _ in TRAFFIC_PACKAGE_FIELDS)
|
||||
TRAFFIC_PACKAGE_ORDER_INDEX: Dict[int, int] = {
|
||||
gb: index for index, gb in enumerate(TRAFFIC_PACKAGE_ORDER)
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChoiceOption:
|
||||
value: Any
|
||||
@@ -178,6 +197,71 @@ SETTING_ENTRY_BY_KEY: Dict[str, SettingEntry] = {
|
||||
}
|
||||
|
||||
|
||||
def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]:
|
||||
order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"])
|
||||
if order_index is not None:
|
||||
return (0, order_index)
|
||||
return (1, package["gb"])
|
||||
|
||||
|
||||
def _collect_traffic_packages() -> List[Dict[str, Any]]:
|
||||
raw_packages = settings.get_traffic_packages()
|
||||
|
||||
packages_map: Dict[int, Dict[str, Any]] = {}
|
||||
for package in raw_packages:
|
||||
gb = int(package.get("gb", 0))
|
||||
packages_map[gb] = {
|
||||
"gb": gb,
|
||||
"price": int(package.get("price") or 0),
|
||||
"enabled": bool(package.get("enabled", True)),
|
||||
"field": TRAFFIC_PACKAGE_FIELD_MAP.get(gb),
|
||||
}
|
||||
|
||||
for gb, field in TRAFFIC_PACKAGE_FIELDS:
|
||||
if not hasattr(settings, field):
|
||||
continue
|
||||
|
||||
price = getattr(settings, field)
|
||||
existing = packages_map.get(gb)
|
||||
enabled = existing["enabled"] if existing is not None else True
|
||||
|
||||
packages_map[gb] = {
|
||||
"gb": gb,
|
||||
"price": int(price),
|
||||
"enabled": enabled,
|
||||
"field": field,
|
||||
}
|
||||
|
||||
packages = list(packages_map.values())
|
||||
packages.sort(key=_traffic_package_sort_key)
|
||||
return packages
|
||||
|
||||
|
||||
def _serialize_traffic_packages(packages: Iterable[Dict[str, Any]]) -> str:
|
||||
parts = []
|
||||
for package in packages:
|
||||
enabled_flag = "true" if package.get("enabled") else "false"
|
||||
parts.append(f"{int(package['gb'])}:{int(package['price'])}:{enabled_flag}")
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
async def _save_traffic_packages(
|
||||
db: AsyncSession,
|
||||
packages: Iterable[Dict[str, Any]],
|
||||
*,
|
||||
skip_if_same: bool = False,
|
||||
) -> bool:
|
||||
new_value = _serialize_traffic_packages(packages)
|
||||
current_value = bot_configuration_service.get_current_value("TRAFFIC_PACKAGES_CONFIG") or ""
|
||||
|
||||
if skip_if_same and current_value == new_value:
|
||||
return False
|
||||
|
||||
await bot_configuration_service.set_value(db, "TRAFFIC_PACKAGES_CONFIG", new_value)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def _language_code(language: str | None) -> str:
|
||||
return (language or "ru").split("-")[0].lower()
|
||||
|
||||
@@ -236,23 +320,17 @@ def _get_period_items(lang_code: str) -> List[PriceItem]:
|
||||
|
||||
|
||||
def _get_traffic_items(lang_code: str) -> List[PriceItem]:
|
||||
traffic_keys: Tuple[Tuple[int, str], ...] = (
|
||||
(5, "PRICE_TRAFFIC_5GB"),
|
||||
(10, "PRICE_TRAFFIC_10GB"),
|
||||
(25, "PRICE_TRAFFIC_25GB"),
|
||||
(50, "PRICE_TRAFFIC_50GB"),
|
||||
(100, "PRICE_TRAFFIC_100GB"),
|
||||
(250, "PRICE_TRAFFIC_250GB"),
|
||||
(500, "PRICE_TRAFFIC_500GB"),
|
||||
(1000, "PRICE_TRAFFIC_1000GB"),
|
||||
(0, "PRICE_TRAFFIC_UNLIMITED"),
|
||||
)
|
||||
packages = _collect_traffic_packages()
|
||||
|
||||
items: List[PriceItem] = []
|
||||
for gb, key in traffic_keys:
|
||||
if hasattr(settings, key):
|
||||
price = getattr(settings, key)
|
||||
items.append((key, _format_traffic_label(gb, lang_code), price))
|
||||
for package in packages:
|
||||
field = package.get("field")
|
||||
if not field:
|
||||
continue
|
||||
|
||||
label = _format_traffic_label(package["gb"], lang_code)
|
||||
icon = "✅" if package["enabled"] else "⚪️"
|
||||
items.append((field, f"{icon} {label}", int(package["price"])))
|
||||
return items
|
||||
|
||||
|
||||
@@ -285,17 +363,17 @@ def _build_period_summary(items: Iterable[PriceItem], lang_code: str, fallback:
|
||||
return ", ".join(parts) if parts else fallback
|
||||
|
||||
|
||||
def _build_traffic_summary(items: Iterable[PriceItem], lang_code: str, fallback: str) -> str:
|
||||
parts: List[str] = []
|
||||
for key, label, price in items:
|
||||
if key.endswith("UNLIMITED"):
|
||||
short_label = "∞"
|
||||
else:
|
||||
digits = ''.join(ch for ch in key if ch.isdigit())
|
||||
unit = "ГБ" if lang_code == "ru" else "GB"
|
||||
short_label = f"{digits}{unit}" if digits else label
|
||||
def _build_traffic_summary(lang_code: str, fallback: str) -> str:
|
||||
packages = _collect_traffic_packages()
|
||||
enabled_packages = [package for package in packages if package["enabled"]]
|
||||
|
||||
parts.append(f"{short_label}: {settings.format_price(price)}")
|
||||
if not enabled_packages:
|
||||
return fallback
|
||||
|
||||
parts: List[str] = []
|
||||
for package in enabled_packages:
|
||||
short_label = _format_traffic_label(package["gb"], lang_code, short=True)
|
||||
parts.append(f"{short_label}: {settings.format_price(int(package['price']))}")
|
||||
|
||||
return ", ".join(parts) if parts else fallback
|
||||
|
||||
@@ -407,6 +485,68 @@ def _build_settings_section(
|
||||
return "\n".join(lines).strip(), keyboard
|
||||
|
||||
|
||||
def _build_traffic_options_section(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
|
||||
texts = get_texts(language)
|
||||
lang_code = _language_code(language)
|
||||
packages = _collect_traffic_packages()
|
||||
|
||||
title = texts.t(
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE",
|
||||
"🚦 Отображение пакетов трафика",
|
||||
)
|
||||
|
||||
lines: List[str] = [title, ""]
|
||||
|
||||
enabled_labels = [
|
||||
_format_traffic_label(package["gb"], lang_code, short=True)
|
||||
for package in packages
|
||||
if package["enabled"]
|
||||
]
|
||||
|
||||
if enabled_labels:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE",
|
||||
"Активные пакеты: {items}",
|
||||
).format(items=", ".join(enabled_labels))
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE",
|
||||
"Активных пакетов нет.",
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
texts.t(
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT",
|
||||
"Нажмите на пакет, чтобы включить или выключить его отображение.",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
|
||||
buttons: List[types.InlineKeyboardButton] = []
|
||||
|
||||
for package in packages:
|
||||
icon = "✅" if package["enabled"] else "⚪️"
|
||||
label = _format_traffic_label(package["gb"], lang_code, short=True)
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{icon} {label}",
|
||||
callback_data=f"admin_pricing_toggle_traffic:{package['gb']}",
|
||||
)
|
||||
)
|
||||
|
||||
for i in range(0, len(buttons), 3):
|
||||
keyboard_rows.append(buttons[i : i + 3])
|
||||
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_pricing")])
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
return "\n".join(lines), keyboard
|
||||
|
||||
|
||||
def _build_period_options_section(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
|
||||
texts = get_texts(language)
|
||||
lang_code = _language_code(language)
|
||||
@@ -485,7 +625,7 @@ def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
|
||||
|
||||
fallback = texts.t("ADMIN_PRICING_SUMMARY_EMPTY", "—")
|
||||
summary_periods = _build_period_summary(period_items, lang_code, fallback)
|
||||
summary_traffic = _build_traffic_summary(traffic_items, lang_code, fallback)
|
||||
summary_traffic = _build_traffic_summary(lang_code, fallback)
|
||||
summary_extra = _build_extra_summary(extra_items, fallback)
|
||||
summary_trial = _format_trial_summary(lang_code)
|
||||
summary_core = _format_core_summary(lang_code)
|
||||
@@ -536,6 +676,13 @@ def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
|
||||
text=texts.t("ADMIN_PRICING_BUTTON_TRAFFIC", "📦 Пакеты трафика"),
|
||||
callback_data="admin_pricing_section:traffic",
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS",
|
||||
"🚦 Отображение пакетов",
|
||||
),
|
||||
callback_data="admin_pricing_section:traffic_options",
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PRICING_BUTTON_EXTRA", "➕ Дополнительно"),
|
||||
callback_data="admin_pricing_section:extra",
|
||||
@@ -564,6 +711,8 @@ def _build_section(
|
||||
elif section == "extra":
|
||||
items = _get_extra_items(lang_code)
|
||||
title = texts.t("ADMIN_PRICING_SECTION_EXTRA_TITLE", "➕ Дополнительные опции")
|
||||
elif section == "traffic_options":
|
||||
return _build_traffic_options_section(language)
|
||||
elif section in SETTING_ENTRIES_BY_SECTION:
|
||||
return _build_settings_section(section, language)
|
||||
elif section == "period_options":
|
||||
@@ -922,14 +1071,27 @@ async def process_pricing_input(
|
||||
await bot_configuration_service.set_value(db, key, new_value)
|
||||
await db.commit()
|
||||
|
||||
if key.startswith("PRICE_TRAFFIC_"):
|
||||
packages = _collect_traffic_packages()
|
||||
await _save_traffic_packages(db, packages, skip_if_same=True)
|
||||
|
||||
section_text, section_keyboard = _build_section(section, db_user.language)
|
||||
|
||||
if mode == "price":
|
||||
label = _resolve_label(section, key, db_user.language)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_PRICING_EDIT_SUCCESS", "Цена для {item} обновлена: {price}").format(
|
||||
item=label,
|
||||
price=settings.format_price(int(new_value)),
|
||||
if message_id:
|
||||
await _render_message_by_id(
|
||||
message.bot,
|
||||
message.chat.id,
|
||||
message_id,
|
||||
section_text,
|
||||
section_keyboard,
|
||||
)
|
||||
)
|
||||
try:
|
||||
await message.delete()
|
||||
except TelegramBadRequest as error:
|
||||
logger.debug("Failed to delete pricing input message: %s", error)
|
||||
await state.clear()
|
||||
return
|
||||
else:
|
||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||
lang_code = _language_code(db_user.language)
|
||||
@@ -1043,6 +1205,57 @@ async def select_setting_choice(
|
||||
await _render_message(callback.message, text, keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_traffic_package(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
try:
|
||||
_, gb_raw = callback.data.split(":", 1)
|
||||
gb_value = int(gb_raw)
|
||||
except (ValueError, TypeError):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
packages = _collect_traffic_packages()
|
||||
|
||||
target_index = next((index for index, pkg in enumerate(packages) if pkg["gb"] == gb_value), None)
|
||||
if target_index is None:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
enabled_count = sum(1 for pkg in packages if pkg["enabled"])
|
||||
target_package = packages[target_index]
|
||||
|
||||
if target_package["enabled"] and enabled_count <= 1:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN",
|
||||
"Должен оставаться хотя бы один пакет.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
target_package["enabled"] = not target_package["enabled"]
|
||||
|
||||
await _save_traffic_packages(db, packages)
|
||||
|
||||
status_text = (
|
||||
texts.t("ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED", "Пакет включен.")
|
||||
if target_package["enabled"]
|
||||
else texts.t("ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED", "Пакет отключен.")
|
||||
)
|
||||
await callback.answer(status_text)
|
||||
|
||||
text, keyboard = _build_traffic_options_section(db_user.language)
|
||||
await _render_message(callback.message, text, keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_period_option(
|
||||
@@ -1127,6 +1340,10 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
select_setting_choice,
|
||||
F.data.startswith("admin_pricing_choice:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_traffic_package,
|
||||
F.data.startswith("admin_pricing_toggle_traffic:"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_period_option,
|
||||
F.data.startswith("admin_pricing_toggle_period:"),
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_timestamp(value: datetime | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _build_overview(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
|
||||
has_content = bool(policy and policy.content and policy.content.strip())
|
||||
|
||||
description = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION",
|
||||
"Политика конфиденциальности отображается в разделе «Инфо».",
|
||||
)
|
||||
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED",
|
||||
"⚠️ Показ политики выключен или текст отсутствует.",
|
||||
)
|
||||
if policy and policy.is_enabled and has_content:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED",
|
||||
"✅ Политика активна и показывается пользователям.",
|
||||
)
|
||||
elif policy and policy.is_enabled:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY",
|
||||
"⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
|
||||
)
|
||||
|
||||
updated_at = _format_timestamp(getattr(policy, "updated_at", None))
|
||||
updated_block = ""
|
||||
if updated_at:
|
||||
updated_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT",
|
||||
"Последнее обновление: {timestamp}",
|
||||
).format(timestamp=updated_at)
|
||||
|
||||
preview_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY",
|
||||
"Текст ещё не задан.",
|
||||
)
|
||||
if has_content:
|
||||
preview_title = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE",
|
||||
"<b>Превью текста:</b>",
|
||||
)
|
||||
preview_raw = policy.content.strip()
|
||||
preview_trimmed = preview_raw[:400]
|
||||
if len(preview_raw) > 400:
|
||||
preview_trimmed += "..."
|
||||
preview_block = (
|
||||
f"{preview_title}\n"
|
||||
f"<code>{html.escape(preview_trimmed)}</code>"
|
||||
)
|
||||
|
||||
language_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE",
|
||||
"Язык: <code>{lang}</code>",
|
||||
).format(lang=normalized_language)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HEADER",
|
||||
"🛡️ <b>Политика конфиденциальности</b>",
|
||||
)
|
||||
actions_prompt = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT",
|
||||
"Выберите действие:",
|
||||
)
|
||||
|
||||
message_parts = [
|
||||
header,
|
||||
description,
|
||||
language_block,
|
||||
status_text,
|
||||
]
|
||||
|
||||
if updated_block:
|
||||
message_parts.append(updated_block)
|
||||
|
||||
message_parts.append(preview_block)
|
||||
message_parts.append(actions_prompt)
|
||||
|
||||
overview_text = "\n\n".join(part for part in message_parts if part)
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
])
|
||||
|
||||
if has_content:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON",
|
||||
"👀 Просмотреть текущий текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_view",
|
||||
)
|
||||
])
|
||||
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON",
|
||||
"✅ Включить показ",
|
||||
)
|
||||
if policy and policy.is_enabled:
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON",
|
||||
"🚫 Отключить показ",
|
||||
)
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data="admin_privacy_policy_toggle",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_privacy_policy_help",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_submenu_settings",
|
||||
)
|
||||
])
|
||||
|
||||
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_privacy_policy_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
|
||||
logger.info(
|
||||
"Админ %s переключил показ политики конфиденциальности: %s",
|
||||
db_user.telegram_id,
|
||||
"enabled" if updated_policy.is_enabled else "disabled",
|
||||
)
|
||||
status_message = (
|
||||
texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена")
|
||||
if updated_policy.is_enabled
|
||||
else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена")
|
||||
)
|
||||
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(status_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
current_preview = ""
|
||||
if policy and policy.content:
|
||||
preview = policy.content.strip()[:400]
|
||||
if len(policy.content.strip()) > 400:
|
||||
preview += "..."
|
||||
current_preview = (
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW",
|
||||
"Текущий текст (превью):",
|
||||
)
|
||||
+ f"\n<code>{html.escape(preview)}</code>\n\n"
|
||||
)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT",
|
||||
"Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
|
||||
)
|
||||
|
||||
hint = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT",
|
||||
"Используйте /html_help для справки по тегам.",
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f"📝 <b>{texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}</b>\n\n"
|
||||
f"{current_preview}{prompt}\n\n{hint}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_privacy_policy_help",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"),
|
||||
callback_data="admin_privacy_policy_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.editing_privacy_policy)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_edit_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await state.clear()
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_privacy_policy_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.text or ""
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG",
|
||||
"❌ Текст политики слишком длинный. Максимум 4000 символов.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
is_valid, error_message = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR",
|
||||
"❌ Ошибка в HTML: {error}",
|
||||
).format(error=error_message)
|
||||
)
|
||||
return
|
||||
|
||||
await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
|
||||
logger.info(
|
||||
"Админ %s обновил текст политики конфиденциальности (%d символов)",
|
||||
db_user.telegram_id,
|
||||
len(new_text),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_SAVED",
|
||||
"✅ Политика конфиденциальности обновлена.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(success_text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
if not policy or not policy.content or not policy.content.strip():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT",
|
||||
"Текст политики пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content = policy.content.strip()
|
||||
truncated = False
|
||||
max_length = 3800
|
||||
if len(content) > max_length:
|
||||
content = content[: max_length - 3] + "..."
|
||||
truncated = True
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE",
|
||||
"👀 <b>Текущий текст политики</b>",
|
||||
)
|
||||
|
||||
note = ""
|
||||
if truncated:
|
||||
note = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED",
|
||||
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{header}\n\n{content}{note}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_privacy_policy_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
help_text = get_html_help_text()
|
||||
|
||||
current_state = await state.get_state()
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if current_state == AdminStates.editing_privacy_policy.state:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT",
|
||||
"⬅️ Назад к редактированию",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy_management,
|
||||
F.data == "admin_privacy_policy",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_privacy_policy,
|
||||
F.data == "admin_privacy_policy_toggle",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_edit_privacy_policy,
|
||||
F.data == "admin_privacy_policy_edit",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
cancel_edit_privacy_policy,
|
||||
F.data == "admin_privacy_policy_cancel",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
view_privacy_policy,
|
||||
F.data == "admin_privacy_policy_view",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy_html_help,
|
||||
F.data == "admin_privacy_policy_help",
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_privacy_policy_edit,
|
||||
AdminStates.editing_privacy_policy,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,530 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_timestamp(value: datetime | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _build_overview(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
normalized_language = PublicOfferService.normalize_language(db_user.language)
|
||||
has_content = bool(offer and offer.content and offer.content.strip())
|
||||
|
||||
description = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION",
|
||||
"Публичная оферта отображается в разделе «Инфо».",
|
||||
)
|
||||
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED",
|
||||
"⚠️ Показ оферты выключен или текст отсутствует.",
|
||||
)
|
||||
if offer and offer.is_enabled and has_content:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED",
|
||||
"✅ Оферта активна и показывается пользователям.",
|
||||
)
|
||||
elif offer and offer.is_enabled:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY",
|
||||
"⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
|
||||
)
|
||||
|
||||
updated_at = _format_timestamp(getattr(offer, "updated_at", None))
|
||||
updated_block = ""
|
||||
if updated_at:
|
||||
updated_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT",
|
||||
"Последнее обновление: {timestamp}",
|
||||
).format(timestamp=updated_at)
|
||||
|
||||
preview_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY",
|
||||
"Текст ещё не задан.",
|
||||
)
|
||||
if has_content:
|
||||
preview_title = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE",
|
||||
"<b>Превью текста:</b>",
|
||||
)
|
||||
preview_raw = offer.content.strip()
|
||||
preview_trimmed = preview_raw[:400]
|
||||
if len(preview_raw) > 400:
|
||||
preview_trimmed += "..."
|
||||
preview_block = (
|
||||
f"{preview_title}\n"
|
||||
f"<code>{html.escape(preview_trimmed)}</code>"
|
||||
)
|
||||
|
||||
language_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE",
|
||||
"Язык: <code>{lang}</code>",
|
||||
).format(lang=normalized_language)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HEADER",
|
||||
"📄 <b>Публичная оферта</b>",
|
||||
)
|
||||
actions_prompt = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT",
|
||||
"Выберите действие:",
|
||||
)
|
||||
|
||||
message_parts = [
|
||||
header,
|
||||
description,
|
||||
language_block,
|
||||
status_text,
|
||||
]
|
||||
|
||||
if updated_block:
|
||||
message_parts.append(updated_block)
|
||||
|
||||
message_parts.append(preview_block)
|
||||
message_parts.append(actions_prompt)
|
||||
|
||||
overview_text = "\n\n".join(part for part in message_parts if part)
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
])
|
||||
|
||||
if has_content:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON",
|
||||
"👀 Просмотреть текущий текст",
|
||||
),
|
||||
callback_data="admin_public_offer_view",
|
||||
)
|
||||
])
|
||||
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON",
|
||||
"✅ Включить показ",
|
||||
)
|
||||
if offer and offer.is_enabled:
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON",
|
||||
"🚫 Отключить показ",
|
||||
)
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data="admin_public_offer_toggle",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_public_offer_help",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_submenu_settings",
|
||||
)
|
||||
])
|
||||
|
||||
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_public_offer_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language)
|
||||
logger.info(
|
||||
"Админ %s переключил показ публичной оферты: %s",
|
||||
db_user.telegram_id,
|
||||
"enabled" if updated_offer.is_enabled else "disabled",
|
||||
)
|
||||
status_message = (
|
||||
texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена")
|
||||
if updated_offer.is_enabled
|
||||
else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена")
|
||||
)
|
||||
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(status_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
current_preview = ""
|
||||
if offer and offer.content:
|
||||
preview = offer.content.strip()[:400]
|
||||
if len(offer.content.strip()) > 400:
|
||||
preview += "..."
|
||||
current_preview = (
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW",
|
||||
"Текущий текст (превью):",
|
||||
)
|
||||
+ f"\n<code>{html.escape(preview)}</code>\n\n"
|
||||
)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT",
|
||||
"Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
|
||||
)
|
||||
|
||||
hint = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT",
|
||||
"Используйте /html_help для справки по тегам.",
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f"📝 <b>{texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}</b>\n\n"
|
||||
f"{current_preview}{prompt}\n\n{hint}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_public_offer_help",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"),
|
||||
callback_data="admin_public_offer_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.editing_public_offer)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_edit_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await state.clear()
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED",
|
||||
"Редактирование оферты отменено.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_public_offer_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.text or ""
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG",
|
||||
"❌ Текст оферты слишком длинный. Максимум 4000 символов.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
is_valid, error_message = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR",
|
||||
"❌ Ошибка в HTML: {error}",
|
||||
).format(error=error_message)
|
||||
)
|
||||
return
|
||||
|
||||
await PublicOfferService.save_offer(db, db_user.language, new_text)
|
||||
logger.info(
|
||||
"Админ %s обновил текст публичной оферты (%d символов)",
|
||||
db_user.telegram_id,
|
||||
len(new_text),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_SAVED",
|
||||
"✅ Публичная оферта обновлена.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(success_text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
if not offer or not offer.content or not offer.content.strip():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
|
||||
"Текст оферты пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content = offer.content.strip()
|
||||
max_length = 3800
|
||||
pages = PublicOfferService.split_content_into_pages(
|
||||
content,
|
||||
max_length=max_length,
|
||||
)
|
||||
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
|
||||
"Текст оферты пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
preview = pages[0]
|
||||
truncated = len(pages) > 1
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE",
|
||||
"👀 <b>Текущий текст оферты</b>",
|
||||
)
|
||||
|
||||
note = ""
|
||||
if truncated:
|
||||
note = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED",
|
||||
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{header}\n\n{preview}{note}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_public_offer_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
help_text = get_html_help_text()
|
||||
|
||||
current_state = await state.get_state()
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if current_state == AdminStates.editing_public_offer.state:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT",
|
||||
"⬅️ Назад к редактированию",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_public_offer_management,
|
||||
F.data == "admin_public_offer",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_public_offer,
|
||||
F.data == "admin_public_offer_toggle",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_edit_public_offer,
|
||||
F.data == "admin_public_offer_edit",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
cancel_edit_public_offer,
|
||||
F.data == "admin_public_offer_cancel",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
view_public_offer,
|
||||
F.data == "admin_public_offer_view",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_public_offer_html_help,
|
||||
F.data == "admin_public_offer_help",
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_public_offer_edit,
|
||||
AdminStates.editing_public_offer,
|
||||
)
|
||||
@@ -68,7 +68,7 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
|
||||
@@ -105,7 +105,7 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<i>🕐 Время: {current_time}</i>
|
||||
@@ -190,7 +190,7 @@ async def show_referral_settings(
|
||||
• Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
• Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
• Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.get_referred_user_reward_kopeks())}
|
||||
|
||||
<b>Комиссионные:</b>
|
||||
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
@@ -35,6 +35,8 @@ def _build_server_edit_view(server):
|
||||
else "Не выбраны"
|
||||
)
|
||||
|
||||
trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
@@ -51,6 +53,7 @@ def _build_server_edit_view(server):
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
• Промогруппы: {promo_groups_text}
|
||||
• Выдача триала: {trial_status}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
@@ -80,6 +83,12 @@ def _build_server_edit_view(server):
|
||||
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎁 Выдавать сквад" if not server.is_trial_eligible else "🚫 Не выдавать сквад",
|
||||
callback_data=f"admin_server_trial_{server.id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
|
||||
@@ -527,6 +536,38 @@ async def toggle_server_availability(
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_trial_assignment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not server.is_trial_eligible
|
||||
await update_server_squad(db, server_id, is_trial_eligible=new_status)
|
||||
|
||||
status_text = "будет выдаваться" if new_status else "перестанет выдаваться"
|
||||
await callback.answer(f"✅ Сквад {status_text} в триал")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_price(
|
||||
@@ -1255,6 +1296,7 @@ def register_handlers(dp: Dispatcher):
|
||||
& ~F.data.contains("promo"),
|
||||
)
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
dp.callback_query.register(toggle_server_trial_assignment, F.data.startswith("admin_server_trial_"))
|
||||
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
|
||||
+54
-13
@@ -2080,11 +2080,16 @@ async def extend_user_subscription(
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⏰ <b>Продление подписки</b>\n\n"
|
||||
"Введите количество дней для продления:\n"
|
||||
"• Например: 30, 7, 90\n"
|
||||
"• Максимум: 365 дней\n\n"
|
||||
"Введите количество дней для изменения:\n"
|
||||
"• Положительные значения продлят подписку\n"
|
||||
"• Отрицательные сократят срок подписки\n"
|
||||
"• Диапазон: от -365 до 365 дней (0 недопустимо)\n\n"
|
||||
"Или нажмите /cancel для отмены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="-7 дней", callback_data=f"admin_sub_extend_days_{user_id}_-7"),
|
||||
types.InlineKeyboardButton(text="-30 дней", callback_data=f"admin_sub_extend_days_{user_id}_-30")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="7 дней", callback_data=f"admin_sub_extend_days_{user_id}_7"),
|
||||
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_extend_days_{user_id}_30")
|
||||
@@ -2114,11 +2119,19 @@ async def process_subscription_extension_days(
|
||||
user_id = int(parts[-2])
|
||||
days = int(parts[-1])
|
||||
|
||||
if days == 0 or days < -365 or days > 365:
|
||||
await callback.answer("❌ Количество дней должно быть от -365 до 365, исключая 0", show_alert=True)
|
||||
return
|
||||
|
||||
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
|
||||
|
||||
|
||||
if success:
|
||||
if days > 0:
|
||||
action_text = f"продлена на {days} дней"
|
||||
else:
|
||||
action_text = f"уменьшена на {abs(days)} дней"
|
||||
await callback.message.edit_text(
|
||||
f"✅ Подписка пользователя продлена на {days} дней",
|
||||
f"✅ Подписка пользователя {action_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
@@ -2153,15 +2166,19 @@ async def process_subscription_extension_text(
|
||||
try:
|
||||
days = int(message.text.strip())
|
||||
|
||||
if days <= 0 or days > 365:
|
||||
await message.answer("❌ Количество дней должно быть от 1 до 365")
|
||||
if days == 0 or days < -365 or days > 365:
|
||||
await message.answer("❌ Количество дней должно быть от -365 до 365, исключая 0")
|
||||
return
|
||||
|
||||
|
||||
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
|
||||
|
||||
|
||||
if success:
|
||||
if days > 0:
|
||||
action_text = f"продлена на {days} дней"
|
||||
else:
|
||||
action_text = f"уменьшена на {abs(days)} дней"
|
||||
await message.answer(
|
||||
f"✅ Подписка пользователя продлена на {days} дней",
|
||||
f"✅ Подписка пользователя {action_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
@@ -3115,9 +3132,12 @@ async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
|
||||
if days > 0:
|
||||
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
|
||||
else:
|
||||
logger.info(f"Админ {admin_id} сократил подписку пользователя {user_id} на {abs(days)} дней")
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка продления подписки: {e}")
|
||||
return False
|
||||
@@ -3240,13 +3260,34 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
logger.error(f"У пользователя {user_id} уже есть подписка")
|
||||
return False
|
||||
|
||||
trial_squads: list[str] = []
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось подобрать сквад при выдаче подписки админом %s: %s",
|
||||
admin_id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=days,
|
||||
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.DEFAULT_DEVICE_LIMIT,
|
||||
connected_squads=[settings.TRIAL_SQUAD_UUID] if settings.TRIAL_SQUAD_UUID else []
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
+759
-3
@@ -1,4 +1,7 @@
|
||||
import html
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -7,9 +10,18 @@ from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_keyboard
|
||||
from app.database.crud.promo_group import (
|
||||
get_auto_assign_promo_groups,
|
||||
has_auto_assign_promo_groups,
|
||||
)
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.keyboards.inline import (
|
||||
get_main_menu_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_info_menu_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.database.models import User
|
||||
from app.database.models import PromoGroup, User
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
@@ -17,10 +29,117 @@ from app.services.subscription_checkout_service import (
|
||||
)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_rubles(amount_kopeks: int) -> str:
|
||||
rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
|
||||
if rubles == rubles.to_integral_value():
|
||||
formatted = f"{rubles:,.0f}"
|
||||
else:
|
||||
formatted = f"{rubles:,.2f}"
|
||||
|
||||
return f"{formatted.replace(',', ' ')} ₽"
|
||||
|
||||
|
||||
def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]:
|
||||
discounts: Dict[int, int] = {}
|
||||
raw_discounts = getattr(group, "period_discounts", None)
|
||||
|
||||
if isinstance(raw_discounts, dict):
|
||||
for key, value in raw_discounts.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized_percent = max(0, min(100, percent))
|
||||
if normalized_percent > 0:
|
||||
discounts[period] = normalized_percent
|
||||
|
||||
if group.is_default and settings.is_base_promo_group_period_discount_enabled():
|
||||
try:
|
||||
base_discounts = settings.get_base_promo_group_period_discounts() or {}
|
||||
except Exception:
|
||||
base_discounts = {}
|
||||
|
||||
for key, value in base_discounts.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if period in discounts:
|
||||
continue
|
||||
|
||||
normalized_percent = max(0, min(100, percent))
|
||||
if normalized_percent > 0:
|
||||
discounts[period] = normalized_percent
|
||||
|
||||
return dict(sorted(discounts.items()))
|
||||
|
||||
|
||||
def _build_group_discount_lines(group: PromoGroup, texts, language: str) -> list[str]:
|
||||
lines: list[str] = []
|
||||
|
||||
if getattr(group, "server_discount_percent", 0) > 0:
|
||||
lines.append(
|
||||
texts.t("PROMO_GROUP_DISCOUNT_SERVERS", "🌍 Серверы: {percent}%").format(
|
||||
percent=group.server_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
if getattr(group, "traffic_discount_percent", 0) > 0:
|
||||
lines.append(
|
||||
texts.t("PROMO_GROUP_DISCOUNT_TRAFFIC", "📊 Трафик: {percent}%").format(
|
||||
percent=group.traffic_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
if getattr(group, "device_discount_percent", 0) > 0:
|
||||
lines.append(
|
||||
texts.t("PROMO_GROUP_DISCOUNT_DEVICES", "📱 Доп. устройства: {percent}%").format(
|
||||
percent=group.device_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
period_discounts = _collect_period_discounts(group)
|
||||
|
||||
if period_discounts:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER",
|
||||
"⏳ Скидки за длительный период:",
|
||||
)
|
||||
)
|
||||
|
||||
for period_days, percent in period_discounts.items():
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM",
|
||||
"{period} — {percent}%",
|
||||
).format(
|
||||
period=format_period_description(period_days, language),
|
||||
percent=percent,
|
||||
)
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -49,6 +168,13 @@ async def show_main_menu(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
@@ -62,6 +188,7 @@ async def show_main_menu(
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -91,6 +218,558 @@ async def show_service_rules(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_info_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
header = texts.t("MENU_INFO_HEADER", "ℹ️ <b>Инфо</b>")
|
||||
prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:")
|
||||
caption = f"{header}\n\n{prompt}" if prompt else header
|
||||
|
||||
privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language)
|
||||
public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language)
|
||||
faq_enabled = await FaqService.is_enabled(db, db_user.language)
|
||||
promo_groups_available = await has_auto_assign_promo_groups(db)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=caption,
|
||||
keyboard=get_info_menu_keyboard(
|
||||
language=db_user.language,
|
||||
show_privacy_policy=privacy_enabled,
|
||||
show_public_offer=public_offer_enabled,
|
||||
show_faq=faq_enabled,
|
||||
show_promo_groups=promo_groups_available,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_promo_groups_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
promo_groups = await get_auto_assign_promo_groups(db)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]]
|
||||
)
|
||||
|
||||
if not promo_groups:
|
||||
empty_text = texts.t(
|
||||
"PROMO_GROUPS_INFO_EMPTY",
|
||||
"Промогруппы с автовыдачей ещё не настроены.",
|
||||
)
|
||||
header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 <b>Промогруппы</b>")
|
||||
message = f"{header}\n\n{empty_text}" if empty_text else header
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
total_spent_kopeks = await get_user_total_spent_kopeks(db, db_user.id)
|
||||
total_spent_text = _format_rubles(total_spent_kopeks)
|
||||
|
||||
sorted_groups = sorted(
|
||||
promo_groups,
|
||||
key=lambda group: (group.auto_assign_total_spent_kopeks or 0, group.id),
|
||||
)
|
||||
|
||||
achieved_groups: List[PromoGroup] = [
|
||||
group
|
||||
for group in sorted_groups
|
||||
if (group.auto_assign_total_spent_kopeks or 0) > 0
|
||||
and total_spent_kopeks >= (group.auto_assign_total_spent_kopeks or 0)
|
||||
]
|
||||
|
||||
current_group = next(
|
||||
(group for group in sorted_groups if group.id == db_user.promo_group_id),
|
||||
None,
|
||||
)
|
||||
|
||||
if not current_group and achieved_groups:
|
||||
current_group = achieved_groups[-1]
|
||||
|
||||
next_group = next(
|
||||
(
|
||||
group
|
||||
for group in sorted_groups
|
||||
if (group.auto_assign_total_spent_kopeks or 0) > total_spent_kopeks
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 <b>Промогруппы</b>")
|
||||
lines: List[str] = [header, ""]
|
||||
|
||||
spent_line = texts.t(
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT",
|
||||
"💰 Потрачено в боте: {amount}",
|
||||
).format(amount=total_spent_text)
|
||||
lines.append(spent_line)
|
||||
|
||||
if current_group:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL",
|
||||
"🏆 Текущий уровень: {name}",
|
||||
).format(name=html.escape(current_group.name)),
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL",
|
||||
"🏆 Текущий уровень: пока не получен",
|
||||
)
|
||||
)
|
||||
|
||||
if next_group:
|
||||
remaining_kopeks = (next_group.auto_assign_total_spent_kopeks or 0) - total_spent_kopeks
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL",
|
||||
"📈 До уровня «{name}»: осталось {amount}",
|
||||
).format(
|
||||
name=html.escape(next_group.name),
|
||||
amount=_format_rubles(max(remaining_kopeks, 0)),
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL",
|
||||
"🏆 Вы уже получили максимальный уровень скидок!",
|
||||
)
|
||||
)
|
||||
|
||||
lines.extend(["", texts.t("PROMO_GROUPS_INFO_LEVELS_HEADER", "📋 Уровни с автовыдачей:")])
|
||||
|
||||
for group in sorted_groups:
|
||||
threshold = group.auto_assign_total_spent_kopeks or 0
|
||||
status_icon = "✅" if total_spent_kopeks >= threshold else "🔒"
|
||||
lines.append(
|
||||
texts.t(
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE",
|
||||
"{status} <b>{name}</b> — от {amount}",
|
||||
).format(
|
||||
status=status_icon,
|
||||
name=html.escape(group.name),
|
||||
amount=_format_rubles(threshold),
|
||||
)
|
||||
)
|
||||
|
||||
discount_lines = _build_group_discount_lines(group, texts, db_user.language)
|
||||
for discount_line in discount_lines:
|
||||
if discount_line:
|
||||
lines.append(f" {discount_line}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
|
||||
message_text = "\n".join(lines)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_faq_pages(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
pages = await FaqService.get_pages(db, db_user.language)
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t("FAQ_NOT_AVAILABLE", "FAQ временно недоступен."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
|
||||
prompt = texts.t("FAQ_PAGES_PROMPT", "Выберите вопрос:" )
|
||||
caption = f"{header}\n\n{prompt}" if prompt else header
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for index, page in enumerate(pages, start=1):
|
||||
raw_title = (page.title or "").strip()
|
||||
if not raw_title:
|
||||
raw_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
|
||||
if len(raw_title) > 60:
|
||||
raw_title = f"{raw_title[:57]}..."
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{index}. {raw_title}",
|
||||
callback_data=f"menu_faq_page:{page.id}:1",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
caption,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_faq_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_data = callback.data or ""
|
||||
parts = raw_data.split(":")
|
||||
|
||||
page_id = None
|
||||
requested_page = 1
|
||||
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
page_id = int(parts[1])
|
||||
except ValueError:
|
||||
page_id = None
|
||||
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
requested_page = int(parts[2])
|
||||
except ValueError:
|
||||
requested_page = 1
|
||||
|
||||
if not page_id:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
page = await FaqService.get_page(db, page_id, db_user.language)
|
||||
|
||||
if not page or not page.is_active:
|
||||
await callback.answer(
|
||||
texts.t("FAQ_PAGE_NOT_AVAILABLE", "Эта страница FAQ недоступна."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content_pages = FaqService.split_content_into_pages(page.content)
|
||||
|
||||
if not content_pages:
|
||||
await callback.answer(
|
||||
texts.t("FAQ_PAGE_EMPTY", "Текст для этой страницы ещё не добавлен."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
total_pages = len(content_pages)
|
||||
current_page = max(1, min(requested_page, total_pages))
|
||||
|
||||
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
|
||||
title_template = texts.t("FAQ_PAGE_TITLE", "<b>{title}</b>")
|
||||
page_title = (page.title or "").strip()
|
||||
if not page_title:
|
||||
page_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
|
||||
title_block = title_template.format(title=html.escape(page_title))
|
||||
|
||||
body = content_pages[current_page - 1]
|
||||
|
||||
footer_template = texts.t(
|
||||
"FAQ_PAGE_FOOTER",
|
||||
"Страница {current} из {total}",
|
||||
)
|
||||
footer = ""
|
||||
if total_pages > 1 and footer_template:
|
||||
try:
|
||||
footer = footer_template.format(current=current_page, total=total_pages)
|
||||
except Exception:
|
||||
footer = f"{current_page}/{total_pages}"
|
||||
|
||||
parts_to_join = [header, title_block]
|
||||
if body:
|
||||
parts_to_join.append(body)
|
||||
if footer:
|
||||
parts_to_join.append(f"<code>{footer}</code>")
|
||||
|
||||
message_text = "\n\n".join(segment for segment in parts_to_join if segment)
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: list[types.InlineKeyboardButton] = []
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"menu_faq_page:{page.id}:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="noop",
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"menu_faq_page:{page.id}:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("FAQ_BACK_TO_LIST", "⬅️ К списку FAQ"),
|
||||
callback_data="menu_faq",
|
||||
)
|
||||
])
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
async def show_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
if callback.data and ":" in callback.data:
|
||||
try:
|
||||
raw_page = int(callback.data.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raw_page = 1
|
||||
|
||||
if raw_page < 1:
|
||||
raw_page = 1
|
||||
|
||||
policy = await PrivacyPolicyService.get_active_policy(db, db_user.language)
|
||||
|
||||
if not policy:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE",
|
||||
"Политика конфиденциальности временно недоступна.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
pages = PrivacyPolicyService.split_content_into_pages(policy.content)
|
||||
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PRIVACY_POLICY_EMPTY_ALERT",
|
||||
"Политика конфиденциальности ещё не заполнена.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
total_pages = len(pages)
|
||||
current_page = raw_page if raw_page <= total_pages else total_pages
|
||||
|
||||
header = texts.t(
|
||||
"PRIVACY_POLICY_HEADER",
|
||||
"🛡️ <b>Политика конфиденциальности</b>",
|
||||
)
|
||||
body = pages[current_page - 1]
|
||||
|
||||
footer_template = texts.t(
|
||||
"PRIVACY_POLICY_PAGE_INFO",
|
||||
"Страница {current} из {total}",
|
||||
)
|
||||
footer = ""
|
||||
if total_pages > 1 and footer_template:
|
||||
try:
|
||||
footer = footer_template.format(current=current_page, total=total_pages)
|
||||
except Exception:
|
||||
footer = f"{current_page}/{total_pages}"
|
||||
|
||||
message_text = header
|
||||
if body:
|
||||
message_text += f"\n\n{body}"
|
||||
if footer:
|
||||
message_text += f"\n\n<code>{footer}</code>"
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: list[types.InlineKeyboardButton] = []
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"menu_privacy_policy:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="noop",
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"menu_privacy_policy:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
raw_page = 1
|
||||
if callback.data and ":" in callback.data:
|
||||
try:
|
||||
raw_page = int(callback.data.split(":", 1)[1])
|
||||
except ValueError:
|
||||
raw_page = 1
|
||||
|
||||
if raw_page < 1:
|
||||
raw_page = 1
|
||||
|
||||
offer = await PublicOfferService.get_active_offer(db, db_user.language)
|
||||
|
||||
if not offer:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE",
|
||||
"Публичная оферта временно недоступна.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
pages = PublicOfferService.split_content_into_pages(offer.content)
|
||||
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PUBLIC_OFFER_EMPTY_ALERT",
|
||||
"Публичная оферта ещё не заполнена.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
total_pages = len(pages)
|
||||
current_page = raw_page if raw_page <= total_pages else total_pages
|
||||
|
||||
header = texts.t(
|
||||
"PUBLIC_OFFER_HEADER",
|
||||
"📄 <b>Публичная оферта</b>",
|
||||
)
|
||||
body = pages[current_page - 1]
|
||||
|
||||
footer_template = texts.t(
|
||||
"PUBLIC_OFFER_PAGE_INFO",
|
||||
"Страница {current} из {total}",
|
||||
)
|
||||
footer = ""
|
||||
if total_pages > 1 and footer_template:
|
||||
try:
|
||||
footer = footer_template.format(current=current_page, total=total_pages)
|
||||
except Exception:
|
||||
footer = f"{current_page}/{total_pages}"
|
||||
|
||||
message_text = header
|
||||
if body:
|
||||
message_text += f"\n\n{body}"
|
||||
if footer:
|
||||
message_text += f"\n\n<code>{footer}</code>"
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row: list[types.InlineKeyboardButton] = []
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"menu_public_offer:{current_page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="noop",
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"menu_public_offer:{current_page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows.append(nav_row)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_language_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -201,6 +880,13 @@ async def handle_back_to_menu(
|
||||
db_user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
@@ -214,6 +900,7 @@ async def handle_back_to_menu(
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
@@ -300,9 +987,38 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
|
||||
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
|
||||
|
||||
info_sections: list[str] = []
|
||||
|
||||
try:
|
||||
promo_hint = await build_promo_offer_hint(db, user, texts)
|
||||
if promo_hint:
|
||||
info_sections.append(promo_hint.strip())
|
||||
except Exception as hint_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
hint_error,
|
||||
)
|
||||
|
||||
try:
|
||||
test_access_hint = await build_test_access_hint(db, user, texts)
|
||||
if test_access_hint:
|
||||
info_sections.append(test_access_hint.strip())
|
||||
except Exception as test_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
test_error,
|
||||
)
|
||||
|
||||
if info_sections:
|
||||
extra_block = "\n\n".join(section for section in info_sections if section)
|
||||
if extra_block:
|
||||
base_text = _insert_random_message(base_text, extra_block, action_prompt)
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
@@ -326,6 +1042,46 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_info_menu,
|
||||
F.data == "menu_info",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promo_groups_info,
|
||||
F.data == "menu_info_promo_groups",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_faq_pages,
|
||||
F.data == "menu_faq",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_faq_page,
|
||||
F.data.startswith("menu_faq_page:"),
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy,
|
||||
F.data == "menu_privacy_policy",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy,
|
||||
F.data.startswith("menu_privacy_policy:"),
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_public_offer,
|
||||
F.data == "menu_public_offer",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_public_offer,
|
||||
F.data.startswith("menu_public_offer:"),
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_language_menu,
|
||||
F.data == "menu_language"
|
||||
|
||||
@@ -31,8 +31,14 @@ from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -329,6 +335,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
user.telegram_id
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
@@ -340,6 +353,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -358,6 +372,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
from sqlalchemy import delete
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
@@ -742,6 +757,13 @@ async def complete_registration_from_callback(
|
||||
and SupportSettingsService.is_moderator(existing_user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
@@ -754,6 +776,7 @@ async def complete_registration_from_callback(
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -914,6 +937,13 @@ async def complete_registration_from_callback(
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
@@ -926,6 +956,7 @@ async def complete_registration_from_callback(
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -980,6 +1011,13 @@ async def complete_registration(
|
||||
and SupportSettingsService.is_moderator(existing_user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
menu_text,
|
||||
@@ -992,6 +1030,7 @@ async def complete_registration(
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -1152,6 +1191,13 @@ async def complete_registration(
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
menu_text,
|
||||
@@ -1164,6 +1210,7 @@ async def complete_registration(
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -1279,6 +1326,35 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
|
||||
|
||||
info_sections: list[str] = []
|
||||
|
||||
try:
|
||||
promo_hint = await build_promo_offer_hint(db, user, texts)
|
||||
if promo_hint:
|
||||
info_sections.append(promo_hint.strip())
|
||||
except Exception as hint_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
hint_error,
|
||||
)
|
||||
|
||||
try:
|
||||
test_access_hint = await build_test_access_hint(db, user, texts)
|
||||
if test_access_hint:
|
||||
info_sections.append(test_access_hint.strip())
|
||||
except Exception as test_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
test_error,
|
||||
)
|
||||
|
||||
if info_sections:
|
||||
extra_block = "\n\n".join(section for section in info_sections if section)
|
||||
if extra_block:
|
||||
base_text = _insert_random_message(base_text, extra_block, action_prompt)
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
@@ -1398,6 +1474,13 @@ async def required_sub_channel_check(
|
||||
and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
)
|
||||
|
||||
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
|
||||
db,
|
||||
is_admin=is_admin,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
)
|
||||
|
||||
keyboard = get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=is_admin,
|
||||
@@ -1407,6 +1490,7 @@ async def required_sub_channel_check(
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
is_moderator=is_moderator,
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
|
||||
+495
-60
@@ -9,14 +9,18 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance, add_user_balance
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
User, TransactionType, SubscriptionStatus,
|
||||
Subscription
|
||||
@@ -52,6 +56,7 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.states import SubscriptionStates
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
@@ -67,12 +72,35 @@ from app.utils.subscription_utils import (
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
|
||||
class _SafeFormatDict(dict):
|
||||
def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
|
||||
return "{" + key + "}"
|
||||
|
||||
|
||||
def _format_text_with_placeholders(template: str, values: Dict[str, Any]) -> str:
|
||||
if not isinstance(template, str):
|
||||
return template
|
||||
|
||||
safe_values = _SafeFormatDict()
|
||||
safe_values.update(values)
|
||||
|
||||
try:
|
||||
return template.format_map(safe_values)
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to format template '%s' with values %s", template, values)
|
||||
return template
|
||||
|
||||
|
||||
def _get_addon_discount_percent_for_user(
|
||||
user: Optional[User],
|
||||
category: str,
|
||||
@@ -110,6 +138,29 @@ def _apply_addon_discount(
|
||||
}
|
||||
|
||||
|
||||
def _get_promo_offer_discount_percent(user: Optional[User]) -> int:
|
||||
return get_user_active_promo_discount_percent(user)
|
||||
|
||||
|
||||
def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]:
|
||||
percent = _get_promo_offer_discount_percent(user)
|
||||
|
||||
if amount <= 0 or percent <= 0:
|
||||
return {"discounted": amount, "discount": 0, "percent": 0}
|
||||
|
||||
discounted, discount_value = apply_percentage_discount(amount, percent)
|
||||
return {"discounted": discounted, "discount": discount_value, "percent": percent}
|
||||
|
||||
|
||||
async def _get_promo_offer_hint(
|
||||
db: AsyncSession,
|
||||
db_user: User,
|
||||
texts,
|
||||
percent: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
return await build_promo_offer_hint(db, db_user, texts, percent)
|
||||
|
||||
|
||||
def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]:
|
||||
if not subscription:
|
||||
return None
|
||||
@@ -245,7 +296,20 @@ async def _prepare_subscription_summary(
|
||||
if not is_valid:
|
||||
raise ValueError("Subscription price calculation validation failed")
|
||||
|
||||
original_total_price = total_price
|
||||
promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
|
||||
if promo_offer_component["discount"] > 0:
|
||||
total_price = promo_offer_component["discounted"]
|
||||
|
||||
summary_data['total_price'] = total_price
|
||||
if promo_offer_component["discount"] > 0:
|
||||
summary_data['promo_offer_discount_percent'] = promo_offer_component["percent"]
|
||||
summary_data['promo_offer_discount_value'] = promo_offer_component["discount"]
|
||||
summary_data['total_price_before_promo_offer'] = original_total_price
|
||||
else:
|
||||
summary_data.pop('promo_offer_discount_percent', None)
|
||||
summary_data.pop('promo_offer_discount_value', None)
|
||||
summary_data.pop('total_price_before_promo_offer', None)
|
||||
summary_data['server_prices_for_period'] = selected_server_prices
|
||||
summary_data['months_in_period'] = months_in_period
|
||||
summary_data['base_price'] = base_price
|
||||
@@ -329,6 +393,17 @@ async def _prepare_subscription_summary(
|
||||
)
|
||||
details_lines.append(devices_line)
|
||||
|
||||
if promo_offer_component["discount"] > 0:
|
||||
details_lines.append(
|
||||
texts.t(
|
||||
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT",
|
||||
"- Промо-предложение: -{amount} ({percent}% дополнительно)",
|
||||
).format(
|
||||
amount=texts.format_price(promo_offer_component["discount"]),
|
||||
percent=promo_offer_component["percent"],
|
||||
)
|
||||
)
|
||||
|
||||
details_text = "\n".join(details_lines)
|
||||
|
||||
summary_text = (
|
||||
@@ -426,19 +501,29 @@ def _build_promo_group_discount_text(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_subscription_period_prompt(db_user: User, texts) -> str:
|
||||
async def _build_subscription_period_prompt(
|
||||
db_user: User,
|
||||
texts,
|
||||
db: AsyncSession,
|
||||
) -> str:
|
||||
base_text = texts.BUY_SUBSCRIPTION_START.rstrip()
|
||||
|
||||
lines: List[str] = [base_text]
|
||||
|
||||
promo_offer_hint = await _get_promo_offer_hint(db, db_user, texts)
|
||||
if promo_offer_hint:
|
||||
lines.extend(["", promo_offer_hint])
|
||||
|
||||
promo_text = _build_promo_group_discount_text(
|
||||
db_user,
|
||||
settings.get_available_subscription_periods(),
|
||||
texts=texts,
|
||||
)
|
||||
|
||||
if not promo_text:
|
||||
return f"{base_text}\n"
|
||||
if promo_text:
|
||||
lines.extend(["", promo_text])
|
||||
|
||||
return f"{base_text}\n\n{promo_text}\n"
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
async def show_subscription_info(
|
||||
@@ -760,8 +845,6 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
except AttributeError:
|
||||
period_discount_percent = 0
|
||||
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
base_cost, _ = apply_percentage_discount(
|
||||
base_cost_original,
|
||||
period_discount_percent,
|
||||
@@ -824,18 +907,34 @@ async def show_trial_offer(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
trial_server_name = "🎯 Тестовый сервер"
|
||||
trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_squad_by_uuid,
|
||||
get_trial_eligible_server_squads,
|
||||
)
|
||||
|
||||
if settings.TRIAL_SQUAD_UUID:
|
||||
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
|
||||
|
||||
if trial_squads:
|
||||
if len(trial_squads) == 1:
|
||||
trial_server_name = trial_squads[0].display_name
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
"TRIAL_SERVER_RANDOM_POOL",
|
||||
"🎲 Случайный из {count} серверов",
|
||||
).format(count=len(trial_squads))
|
||||
elif settings.TRIAL_SQUAD_UUID:
|
||||
trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID)
|
||||
if trial_server:
|
||||
trial_server_name = trial_server.display_name
|
||||
else:
|
||||
logger.warning(f"Триальный сервер с UUID {settings.TRIAL_SQUAD_UUID} не найден в БД")
|
||||
logger.warning(
|
||||
"Триальный сервер с UUID %s не найден в БД",
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
else:
|
||||
logger.warning("TRIAL_SQUAD_UUID не настроен в конфигурации")
|
||||
logger.warning("Не настроены сквады для выдачи триалов")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения триального сервера: {e}")
|
||||
@@ -1032,13 +1131,15 @@ async def activate_trial(
|
||||
async def start_subscription_purchase(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
_build_subscription_period_prompt(db_user, texts),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language)
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
subscription = getattr(db_user, 'subscription', None)
|
||||
@@ -2373,14 +2474,13 @@ async def handle_extend_subscription(
|
||||
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
renewal_prices = {}
|
||||
promo_offer_percent = _get_promo_offer_discount_percent(db_user)
|
||||
|
||||
for days in available_periods:
|
||||
try:
|
||||
months_in_period = calculate_months_from_days(days)
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(days, 0)
|
||||
period_discount_percent = db_user.get_promo_discount("period", days)
|
||||
base_price, _ = apply_percentage_discount(
|
||||
@@ -2417,8 +2517,20 @@ async def handle_extend_subscription(
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period
|
||||
|
||||
total_original_price = (
|
||||
base_price_original
|
||||
+ servers_price_per_month * months_in_period
|
||||
+ devices_price_per_month * months_in_period
|
||||
+ traffic_price_per_month * months_in_period
|
||||
)
|
||||
|
||||
price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
renewal_prices[days] = price
|
||||
promo_component = _apply_promo_offer_discount(db_user, price)
|
||||
|
||||
renewal_prices[days] = {
|
||||
"final": promo_component["discounted"],
|
||||
"original": total_original_price,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка расчета цены для периода {days}: {e}")
|
||||
@@ -2431,9 +2543,35 @@ async def handle_extend_subscription(
|
||||
prices_text = ""
|
||||
|
||||
for days in available_periods:
|
||||
if days in renewal_prices:
|
||||
period_display = format_period_description(days, db_user.language)
|
||||
prices_text += f"📅 {period_display} - {texts.format_price(renewal_prices[days])}\n"
|
||||
if days not in renewal_prices:
|
||||
continue
|
||||
|
||||
price_info = renewal_prices[days]
|
||||
|
||||
if isinstance(price_info, dict):
|
||||
final_price = price_info.get("final")
|
||||
if final_price is None:
|
||||
final_price = price_info.get("original", 0)
|
||||
original_price = price_info.get("original", final_price)
|
||||
else:
|
||||
final_price = price_info
|
||||
original_price = final_price
|
||||
|
||||
has_discount = original_price > final_price
|
||||
|
||||
period_display = format_period_description(days, db_user.language)
|
||||
|
||||
if has_discount:
|
||||
prices_text += (
|
||||
"📅 "
|
||||
f"{period_display} - <s>{texts.format_price(original_price)}</s> "
|
||||
f"{texts.format_price(final_price)}\n"
|
||||
)
|
||||
else:
|
||||
prices_text += (
|
||||
"📅 "
|
||||
f"{period_display} - {texts.format_price(final_price)}\n"
|
||||
)
|
||||
|
||||
promo_discounts_text = _build_promo_group_discount_text(
|
||||
db_user,
|
||||
@@ -2455,6 +2593,15 @@ async def handle_extend_subscription(
|
||||
if promo_discounts_text:
|
||||
message_text += f"{promo_discounts_text}\n\n"
|
||||
|
||||
promo_offer_hint = await _get_promo_offer_hint(
|
||||
db,
|
||||
db_user,
|
||||
texts,
|
||||
promo_offer_percent,
|
||||
)
|
||||
if promo_offer_hint:
|
||||
message_text += f"{promo_offer_hint}\n\n"
|
||||
|
||||
message_text += "💡 <i>Цена включает все ваши текущие серверы и настройки</i>"
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -2666,7 +2813,6 @@ async def confirm_extend_subscription(
|
||||
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
base_price_original = PERIOD_PRICES.get(days, 0)
|
||||
period_discount_percent = db_user.get_promo_discount("period", days)
|
||||
@@ -2719,13 +2865,17 @@ async def confirm_extend_subscription(
|
||||
total_traffic_price = discounted_traffic_price_per_month * months_in_period
|
||||
|
||||
price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
original_price = price
|
||||
promo_component = _apply_promo_offer_discount(db_user, price)
|
||||
if promo_component["discount"] > 0:
|
||||
price = promo_component["discounted"]
|
||||
|
||||
monthly_additions = (
|
||||
discounted_servers_price_per_month
|
||||
+ discounted_devices_price_per_month
|
||||
+ discounted_traffic_price_per_month
|
||||
)
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price)
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, original_price)
|
||||
|
||||
if not is_valid:
|
||||
logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}")
|
||||
@@ -2773,6 +2923,12 @@ async def confirm_extend_subscription(
|
||||
else ""
|
||||
)
|
||||
)
|
||||
if promo_component["discount"] > 0:
|
||||
logger.info(
|
||||
" 🎯 Промо-предложение: -%s₽ (%s%%)",
|
||||
promo_component["discount"] / 100,
|
||||
promo_component["percent"],
|
||||
)
|
||||
logger.info(f" 💎 ИТОГО: {price / 100}₽")
|
||||
|
||||
except Exception as e:
|
||||
@@ -2811,8 +2967,11 @@ async def confirm_extend_subscription(
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price,
|
||||
f"Продление подписки на {days} дней"
|
||||
db,
|
||||
db_user,
|
||||
price,
|
||||
f"Продление подписки на {days} дней",
|
||||
consume_promo_offer=promo_component["discount"] > 0,
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -2903,6 +3062,12 @@ async def confirm_extend_subscription(
|
||||
f"💰 Списано: {texts.format_price(price)}"
|
||||
)
|
||||
|
||||
if promo_component["discount"] > 0:
|
||||
success_message += (
|
||||
f" (включая доп. скидку {promo_component['percent']}%:"
|
||||
f" -{texts.format_price(promo_component['discount'])})"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_message,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
@@ -3463,8 +3628,6 @@ async def confirm_purchase(
|
||||
discounted_servers_price_per_month = 0
|
||||
server_prices = []
|
||||
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
for server_price_per_month in per_month_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
@@ -3504,8 +3667,6 @@ async def confirm_purchase(
|
||||
"devices",
|
||||
data['period_days'],
|
||||
)
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
@@ -3538,8 +3699,6 @@ async def confirm_purchase(
|
||||
"traffic",
|
||||
data['period_days'],
|
||||
)
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
@@ -3549,7 +3708,26 @@ async def confirm_purchase(
|
||||
|
||||
total_servers_price = data.get('total_servers_price', total_countries_price)
|
||||
|
||||
final_price = data['total_price']
|
||||
cached_total_price = data['total_price']
|
||||
cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
|
||||
|
||||
validation_total_price = data.get('total_price_before_promo_offer')
|
||||
if validation_total_price is None and cached_promo_discount_value > 0:
|
||||
validation_total_price = cached_total_price + cached_promo_discount_value
|
||||
if validation_total_price is None:
|
||||
validation_total_price = cached_total_price
|
||||
|
||||
current_promo_offer_percent = _get_promo_offer_discount_percent(db_user)
|
||||
if current_promo_offer_percent > 0:
|
||||
final_price, promo_offer_discount_value = apply_percentage_discount(
|
||||
validation_total_price,
|
||||
current_promo_offer_percent,
|
||||
)
|
||||
promo_offer_discount_percent = current_promo_offer_percent
|
||||
else:
|
||||
final_price = validation_total_price
|
||||
promo_offer_discount_value = 0
|
||||
promo_offer_discount_percent = 0
|
||||
|
||||
discounted_monthly_additions = data.get(
|
||||
'discounted_monthly_additions',
|
||||
@@ -3562,7 +3740,7 @@ async def confirm_purchase(
|
||||
base_price,
|
||||
discounted_monthly_additions,
|
||||
months_in_period,
|
||||
final_price,
|
||||
validation_total_price,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
@@ -3611,6 +3789,12 @@ async def confirm_purchase(
|
||||
f" -{devices_discount_total / 100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if promo_offer_discount_value > 0:
|
||||
logger.info(
|
||||
" 🎯 Промо-предложение: -%s₽ (%s%%)",
|
||||
promo_offer_discount_value / 100,
|
||||
promo_offer_discount_percent,
|
||||
)
|
||||
logger.info(f" ИТОГО: {final_price / 100}₽")
|
||||
|
||||
if db_user.balance_kopeks < final_price:
|
||||
@@ -3646,8 +3830,11 @@ async def confirm_purchase(
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, final_price,
|
||||
f"Покупка подписки на {data['period_days']} дней"
|
||||
db,
|
||||
db_user,
|
||||
final_price,
|
||||
f"Покупка подписки на {data['period_days']} дней",
|
||||
consume_promo_offer=promo_offer_discount_value > 0,
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -3810,6 +3997,16 @@ async def confirm_purchase(
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
discount_note = ""
|
||||
if promo_offer_discount_value > 0:
|
||||
discount_note = texts.t(
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE",
|
||||
"⚡ Доп. скидка {percent}%: -{amount}",
|
||||
).format(
|
||||
percent=promo_offer_discount_percent,
|
||||
amount=texts.format_price(promo_offer_discount_value),
|
||||
)
|
||||
|
||||
if remnawave_user and subscription_link:
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
success_text = (
|
||||
@@ -3849,6 +4046,9 @@ async def confirm_purchase(
|
||||
f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
|
||||
)
|
||||
|
||||
if discount_note:
|
||||
success_text = f"{success_text}\n\n{discount_note}"
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
@@ -3922,11 +4122,14 @@ async def confirm_purchase(
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
purchase_text = texts.SUBSCRIPTION_PURCHASED
|
||||
if discount_note:
|
||||
purchase_text = f"{purchase_text}\n\n{discount_note}"
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE",
|
||||
"{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
|
||||
).format(purchase_text=texts.SUBSCRIPTION_PURCHASED),
|
||||
).format(purchase_text=purchase_text),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@@ -4140,7 +4343,8 @@ async def create_paid_subscription_with_traffic_mode(
|
||||
duration_days=duration_days,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=False,
|
||||
)
|
||||
|
||||
logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
|
||||
@@ -4314,8 +4518,9 @@ async def handle_subscription_config_back(
|
||||
|
||||
if current_state == SubscriptionStates.selecting_traffic.state:
|
||||
await callback.message.edit_text(
|
||||
_build_subscription_period_prompt(db_user, texts),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language)
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
@@ -4328,8 +4533,9 @@ async def handle_subscription_config_back(
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
_build_subscription_period_prompt(db_user, texts),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language)
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
@@ -4352,8 +4558,9 @@ async def handle_subscription_config_back(
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
_build_subscription_period_prompt(db_user, texts),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language)
|
||||
await _build_subscription_period_prompt(db_user, texts, db),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
@@ -5052,33 +5259,256 @@ async def claim_discount_offer(
|
||||
)
|
||||
return
|
||||
|
||||
bonus_amount = offer.bonus_amount_kopeks or 0
|
||||
if bonus_amount > 0:
|
||||
success = await add_user_balance(
|
||||
effect_type = (offer.effect_type or "percent_discount").lower()
|
||||
if effect_type == "balance_bonus":
|
||||
effect_type = "percent_discount"
|
||||
|
||||
if effect_type == "test_access":
|
||||
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
|
||||
db,
|
||||
db_user,
|
||||
bonus_amount,
|
||||
texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"),
|
||||
offer,
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer(
|
||||
texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."),
|
||||
show_alert=True,
|
||||
)
|
||||
if error_code == "subscription_missing":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_NO_SUBSCRIPTION",
|
||||
"❌ Для активации предложения необходима действующая подписка.",
|
||||
)
|
||||
elif error_code == "squads_missing":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_NO_SQUADS",
|
||||
"❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
|
||||
)
|
||||
elif error_code == "already_connected":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_ALREADY_CONNECTED",
|
||||
"ℹ️ Этот сервер уже подключен к вашей подписке.",
|
||||
)
|
||||
elif error_code == "remnawave_sync_failed":
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_REMNAWAVE_ERROR",
|
||||
"❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
else:
|
||||
error_message = texts.get(
|
||||
"TEST_ACCESS_UNKNOWN_ERROR",
|
||||
"❌ Не удалось активировать предложение. Попробуйте позже.",
|
||||
)
|
||||
await callback.answer(error_message, show_alert=True)
|
||||
return
|
||||
|
||||
await mark_offer_claimed(db, offer)
|
||||
await mark_offer_claimed(
|
||||
db,
|
||||
offer,
|
||||
details={
|
||||
"context": "test_access_claim",
|
||||
"new_squads": newly_added,
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
},
|
||||
)
|
||||
|
||||
success_message = texts.get(
|
||||
expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else ""
|
||||
success_message = texts.get(
|
||||
"TEST_ACCESS_ACTIVATED_MESSAGE",
|
||||
"🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
|
||||
).format(expires_at=expires_text)
|
||||
|
||||
popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!")
|
||||
await callback.answer(popup_text, show_alert=True)
|
||||
back_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.get("BACK_TO_MENU", "🏠 В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.answer(success_message, reply_markup=back_keyboard)
|
||||
return
|
||||
|
||||
discount_percent = int(offer.discount_percent or 0)
|
||||
if discount_percent <= 0:
|
||||
await callback.answer(
|
||||
texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
db_user.promo_offer_discount_percent = discount_percent
|
||||
db_user.promo_offer_discount_source = offer.notification_type
|
||||
db_user.updated_at = now
|
||||
|
||||
extra_data = offer.extra_data or {}
|
||||
raw_duration = extra_data.get("active_discount_hours")
|
||||
template_id = extra_data.get("template_id")
|
||||
|
||||
if raw_duration in (None, "") and template_id:
|
||||
try:
|
||||
template = await get_promo_offer_template_by_id(db, int(template_id))
|
||||
except (ValueError, TypeError):
|
||||
template = None
|
||||
if template and template.active_discount_hours:
|
||||
raw_duration = template.active_discount_hours
|
||||
|
||||
try:
|
||||
duration_hours = int(raw_duration) if raw_duration is not None else None
|
||||
except (TypeError, ValueError):
|
||||
duration_hours = None
|
||||
|
||||
if duration_hours and duration_hours > 0:
|
||||
discount_expires_at = now + timedelta(hours=duration_hours)
|
||||
else:
|
||||
discount_expires_at = None
|
||||
|
||||
db_user.promo_offer_discount_expires_at = discount_expires_at
|
||||
|
||||
await mark_offer_claimed(
|
||||
db,
|
||||
offer,
|
||||
details={
|
||||
"context": "discount_claim",
|
||||
"discount_percent": discount_percent,
|
||||
"discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
|
||||
},
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
|
||||
success_template = texts.get(
|
||||
"DISCOUNT_CLAIM_SUCCESS",
|
||||
"🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
|
||||
).format(
|
||||
percent=offer.discount_percent,
|
||||
amount=settings.format_price(bonus_amount),
|
||||
"🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
|
||||
)
|
||||
|
||||
expires_text = (
|
||||
discount_expires_at.strftime("%d.%m.%Y %H:%M") if discount_expires_at else ""
|
||||
)
|
||||
|
||||
format_values: Dict[str, Any] = {"percent": discount_percent}
|
||||
|
||||
if duration_hours and duration_hours > 0:
|
||||
format_values.setdefault("hours", duration_hours)
|
||||
format_values.setdefault("duration_hours", duration_hours)
|
||||
|
||||
if discount_expires_at:
|
||||
format_values.setdefault("expires_at", expires_text)
|
||||
format_values.setdefault("expires_at_iso", discount_expires_at.isoformat())
|
||||
try:
|
||||
expires_timestamp = int(discount_expires_at.timestamp())
|
||||
except (OverflowError, OSError, ValueError):
|
||||
expires_timestamp = None
|
||||
if expires_timestamp:
|
||||
format_values.setdefault("expires_at_ts", expires_timestamp)
|
||||
remaining_hours = int((discount_expires_at - now).total_seconds() // 3600)
|
||||
if remaining_hours > 0:
|
||||
format_values.setdefault("expires_in_hours", remaining_hours)
|
||||
|
||||
amount_text = ""
|
||||
if isinstance(extra_data, dict):
|
||||
raw_amount_text = (
|
||||
extra_data.get("amount_text")
|
||||
or extra_data.get("discount_amount_text")
|
||||
or extra_data.get("formatted_amount")
|
||||
)
|
||||
if isinstance(raw_amount_text, str) and raw_amount_text.strip():
|
||||
amount_text = raw_amount_text.strip()
|
||||
else:
|
||||
raw_amount = extra_data.get("amount") or extra_data.get("discount_amount")
|
||||
if isinstance(raw_amount, (int, float)):
|
||||
amount_text = settings.format_price(int(raw_amount))
|
||||
elif isinstance(raw_amount, str) and raw_amount.strip():
|
||||
amount_text = raw_amount.strip()
|
||||
|
||||
if not amount_text:
|
||||
for key in ("discount_amount_kopeks", "amount_kopeks", "bonus_amount_kopeks"):
|
||||
maybe_amount = extra_data.get(key)
|
||||
try:
|
||||
amount_value = int(maybe_amount)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if amount_value > 0:
|
||||
amount_text = settings.format_price(amount_value)
|
||||
break
|
||||
|
||||
for key, value in extra_data.items():
|
||||
if (
|
||||
isinstance(key, str)
|
||||
and key.isidentifier()
|
||||
and key not in format_values
|
||||
and isinstance(value, (str, int, float))
|
||||
):
|
||||
format_values[key] = value
|
||||
|
||||
if not amount_text:
|
||||
try:
|
||||
bonus_amount = int(getattr(offer, "bonus_amount_kopeks", 0))
|
||||
except (TypeError, ValueError):
|
||||
bonus_amount = 0
|
||||
if bonus_amount > 0:
|
||||
amount_text = settings.format_price(bonus_amount)
|
||||
|
||||
if amount_text:
|
||||
format_values.setdefault("amount", amount_text)
|
||||
|
||||
success_message = _format_text_with_placeholders(success_template, format_values)
|
||||
|
||||
await callback.answer("✅ Скидка активирована!", show_alert=True)
|
||||
await callback.message.answer(success_message)
|
||||
|
||||
offer_type = None
|
||||
if isinstance(extra_data, dict):
|
||||
offer_type = extra_data.get("offer_type")
|
||||
|
||||
subscription = getattr(db_user, "subscription", None)
|
||||
|
||||
if offer_type == "purchase_discount":
|
||||
button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
|
||||
button_callback = "subscription_upgrade"
|
||||
elif offer_type == "extend_discount":
|
||||
button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
|
||||
button_callback = "subscription_extend"
|
||||
else:
|
||||
has_active_paid_subscription = bool(
|
||||
subscription
|
||||
and getattr(subscription, "is_active", False)
|
||||
and not getattr(subscription, "is_trial", False)
|
||||
)
|
||||
|
||||
if has_active_paid_subscription:
|
||||
button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
|
||||
button_callback = "subscription_extend"
|
||||
else:
|
||||
button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
|
||||
button_callback = "subscription_upgrade"
|
||||
|
||||
buy_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=button_callback,
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.answer(success_message, reply_markup=buy_keyboard)
|
||||
|
||||
|
||||
async def handle_promo_offer_close(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.edit_reply_markup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_device_guide(
|
||||
@@ -6124,6 +6554,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("claim_discount_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_promo_offer_close,
|
||||
F.data == "promo_offer_close",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_happ_download_request,
|
||||
F.data == "subscription_happ_download"
|
||||
|
||||
@@ -101,6 +101,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
|
||||
callback_data="admin_promo_offers"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_COMMUNICATIONS_WELCOME_TEXT", "👋 Приветственный текст"),
|
||||
@@ -172,6 +178,24 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
|
||||
callback_data="maintenance_panel"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SETTINGS_PRIVACY_POLICY", "🛡️ Политика конф."),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SETTINGS_PUBLIC_OFFER", "📄 Публичная оферта"),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SETTINGS_FAQ", "❓ FAQ"),
|
||||
callback_data="admin_faq",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")
|
||||
]
|
||||
|
||||
+111
-36
@@ -121,6 +121,7 @@ def get_main_menu_keyboard(
|
||||
show_resume_checkout: bool = False,
|
||||
*,
|
||||
is_moderator: bool = False,
|
||||
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -230,6 +231,11 @@ def get_main_menu_keyboard(
|
||||
)
|
||||
])
|
||||
|
||||
if custom_buttons:
|
||||
for button in custom_buttons:
|
||||
if isinstance(button, InlineKeyboardButton):
|
||||
keyboard.append([button])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
|
||||
@@ -237,41 +243,23 @@ def get_main_menu_keyboard(
|
||||
]
|
||||
])
|
||||
|
||||
# Server status button
|
||||
server_status_mode = settings.get_server_status_mode()
|
||||
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
|
||||
|
||||
if server_status_mode == "external_link":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=server_status_text, url=status_url)
|
||||
])
|
||||
elif server_status_mode == "external_link_miniapp":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=server_status_text,
|
||||
web_app=types.WebAppInfo(url=status_url),
|
||||
)
|
||||
])
|
||||
elif server_status_mode == "xray":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=server_status_text, callback_data="menu_server_status")
|
||||
])
|
||||
|
||||
# Support button is configurable (runtime via service)
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
support_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
support_row = []
|
||||
if support_enabled:
|
||||
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)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_INFO", "ℹ️ Инфо"),
|
||||
callback_data="menu_info",
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard.append([
|
||||
@@ -298,6 +286,82 @@ def get_main_menu_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_info_menu_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
show_privacy_policy: bool = False,
|
||||
show_public_offer: bool = False,
|
||||
show_faq: bool = False,
|
||||
show_promo_groups: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
buttons: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
if show_faq:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_FAQ", "❓ FAQ"),
|
||||
callback_data="menu_faq",
|
||||
)
|
||||
])
|
||||
|
||||
if show_promo_groups:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_PROMO_GROUPS_INFO", "🎯 Промогруппы"),
|
||||
callback_data="menu_info_promo_groups",
|
||||
)
|
||||
])
|
||||
|
||||
if show_privacy_policy:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_PRIVACY_POLICY", "🛡️ Политика конф."),
|
||||
callback_data="menu_privacy_policy",
|
||||
)
|
||||
])
|
||||
|
||||
if show_public_offer:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_PUBLIC_OFFER", "📄 Оферта"),
|
||||
callback_data="menu_public_offer",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
|
||||
])
|
||||
|
||||
server_status_mode = settings.get_server_status_mode()
|
||||
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
|
||||
|
||||
if server_status_mode == "external_link":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
buttons.append([InlineKeyboardButton(text=server_status_text, url=status_url)])
|
||||
elif server_status_mode == "external_link_miniapp":
|
||||
status_url = settings.get_server_status_external_url()
|
||||
if status_url:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=server_status_text,
|
||||
web_app=types.WebAppInfo(url=status_url),
|
||||
)
|
||||
])
|
||||
elif server_status_mode == "xray":
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=server_status_text,
|
||||
callback_data="menu_server_status",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]:
|
||||
if not settings.is_happ_download_button_enabled():
|
||||
return None
|
||||
@@ -1646,14 +1710,25 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
for days in available_periods:
|
||||
if days in prices:
|
||||
period_display = format_period_description(days, language)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display} - {texts.format_price(prices[days])}",
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
if days not in prices:
|
||||
continue
|
||||
|
||||
price_info = prices[days]
|
||||
|
||||
if isinstance(price_info, dict):
|
||||
final_price = price_info.get("final")
|
||||
if final_price is None:
|
||||
final_price = price_info.get("original", 0)
|
||||
else:
|
||||
final_price = price_info
|
||||
|
||||
period_display = format_period_description(days, language)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display} - {texts.format_price(final_price)}",
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"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_TEST_ACCESS_HEADER": "🧪 Test servers active: {servers}",
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n<code>{bar}</code>",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"DEVICE_UUID_NOT_FOUND": "❌ User UUID not found",
|
||||
@@ -275,6 +277,7 @@
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
|
||||
"CONTACT_SUPPORT": "💬 Contact support",
|
||||
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram blocked the contact request button because of your privacy settings.</b>\n\nAllow sharing your contact details in Telegram settings or send the required information manually in the chat.",
|
||||
"CREATE_INVITE": "📝 Create invite",
|
||||
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
|
||||
@@ -299,6 +302,19 @@
|
||||
"MENU_ADMIN": "⚙️ Admin panel",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
|
||||
"MENU_INFO": "ℹ️ Info",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Info</b>",
|
||||
"MENU_INFO_PROMPT": "Choose a section:",
|
||||
"MENU_PROMO_GROUPS_INFO": "🎯 Promo groups",
|
||||
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Promo groups</b>",
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Spent in the bot: {amount}",
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Current level: {name}",
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Current level: not unlocked yet",
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 Until level “{name}”: spend {amount} more",
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 You already have the highest discount level!",
|
||||
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Auto-assigned levels:",
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — from {amount}",
|
||||
"PROMO_GROUPS_INFO_EMPTY": "Auto-assigned promo groups are not configured yet.",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
@@ -372,6 +388,8 @@
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_SERVER_RANDOM_POOL": "🎲 Random choice among {count} servers",
|
||||
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Trial server",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
|
||||
@@ -522,6 +540,16 @@
|
||||
"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_PROMO_OFFER_TEST_DURATION": "Access: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Server: {name}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified",
|
||||
"ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}",
|
||||
"ADMIN_PROMO_OFFER_SKIPPED": "Skipped: {skipped} (already have access)",
|
||||
"ADMIN_PROMO_OFFER_CTA_BUY": "Buy subscription",
|
||||
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Extend subscription",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ Back to offer",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ Back to promo offers",
|
||||
"ADMIN_REPORTS": "📊 Reports",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
|
||||
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Closed support tickets:",
|
||||
@@ -548,11 +576,17 @@
|
||||
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
|
||||
"DELETE_MESSAGE": "🗑 Delete",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
|
||||
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
|
||||
"PROMO_OFFER_CLOSE": "❌ Close",
|
||||
"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.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically to your next payment.",
|
||||
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ This server is already connected to your subscription.",
|
||||
"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",
|
||||
@@ -592,8 +626,8 @@
|
||||
"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_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nActivate the offer to get an additional discount. It stacks with your promo group and 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 and activate the extra discount — it stacks with your promo group and is 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.",
|
||||
@@ -698,5 +732,50 @@
|
||||
"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"
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted",
|
||||
"MENU_PUBLIC_OFFER": "📄 Offer",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Public Offer</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
|
||||
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Public offer</b>",
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Text preview:</b>",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
|
||||
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Current offer text</b>",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled.",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Package visibility",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Package enabled.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Package disabled.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "At least one package must remain.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Traffic package visibility",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Active packages: {items}",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "No active packages.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Tap a package to toggle its visibility."
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
|
||||
"CONTACT_SUPPORT": "💬 Написать в поддержку",
|
||||
"CONTINUE": "➡️ Продолжить",
|
||||
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram не позволяет боту запросить контакт из-за ваших настроек приватности.</b>\n\nРазрешите отправку контакта в настройках Telegram или поделитесь данными вручную в ответ на сообщение.",
|
||||
"CONTINUE_BUTTON": "✅ Продолжить",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
|
||||
"CREATE_INVITE": "📝 Создать приглашение",
|
||||
@@ -202,6 +203,8 @@
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {servers}",
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n<code>{bar}</code>",
|
||||
"MAIN_MENU_BUTTON": "🏠 Главное меню",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
|
||||
"DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден",
|
||||
@@ -228,6 +231,29 @@
|
||||
"MENU_BALANCE": "💰 Баланс",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
|
||||
"MENU_INFO": "ℹ️ Инфо",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Инфо</b>",
|
||||
"MENU_INFO_PROMPT": "Выберите раздел:",
|
||||
"MENU_PROMO_GROUPS_INFO": "🎯 Скидки за траты",
|
||||
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Скидки за траты</b>",
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Потрачено в боте: {amount}",
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Текущий уровень: {name}",
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Текущий уровень: пока не получен",
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 До уровня «{name}»: осталось {amount}",
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 Вы уже получили максимальный уровень скидок!",
|
||||
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Уровни с автовыдачей:",
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — от {amount}",
|
||||
"PROMO_GROUPS_INFO_EMPTY": "Промогруппы с автовыдачей ещё не настроены.",
|
||||
"MENU_PRIVACY_POLICY": "🛡️ Политика конф.",
|
||||
"MENU_PUBLIC_OFFER": "📄 Оферта",
|
||||
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
|
||||
"PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.",
|
||||
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
@@ -524,6 +550,16 @@
|
||||
"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_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Сервер: {name}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны",
|
||||
"ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",
|
||||
"ADMIN_PROMO_OFFER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
|
||||
"ADMIN_PROMO_OFFER_CTA_BUY": "Купить подписку",
|
||||
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Продлить подписку",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ К предложению",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ К промопредложениям",
|
||||
"ADMIN_REPORTS": "📊 Отчеты",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
|
||||
@@ -548,11 +584,17 @@
|
||||
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
|
||||
"DELETE_MESSAGE": "🗑 Удалить",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
|
||||
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
|
||||
"PROMO_OFFER_CLOSE": "❌ Закрыть",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 <b>Скидка {percent}% активирована!</b> \n\nОна суммируется с другими скидками и автоматически применится при следующей оплате.",
|
||||
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ Этот сервер уже подключен к вашей подписке.",
|
||||
"ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
|
||||
"LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
|
||||
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
|
||||
@@ -592,8 +634,8 @@
|
||||
"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_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
|
||||
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
|
||||
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
|
||||
@@ -661,6 +703,69 @@
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.",
|
||||
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "Политика конфиденциальности отображается в разделе «Инфо».",
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Показ политики выключен или текст отсутствует.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Политика активна и показывается пользователям.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Последнее обновление: {timestamp}",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Превью текста:</b>",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Изменить текст",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 Просмотреть текущий текст",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Текущий текст (превью):",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Используйте /html_help для справки по тегам.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Редактирование политики",
|
||||
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ Текст политики слишком длинный. Максимум 4000 символов.",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Политика конфиденциальности обновлена.",
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ К настройкам политики",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "Текст политики пока не задан.",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Текущий текст политики</b>",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена",
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
|
||||
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Превью текста:</b>",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
|
||||
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Текущий текст оферты</b>",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено.",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
|
||||
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
|
||||
@@ -698,5 +803,13 @@
|
||||
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.",
|
||||
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже",
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено"
|
||||
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Отображение пакетов",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Пакет включен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Пакет отключен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "Должен оставаться хотя бы один пакет.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Отображение пакетов трафика",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Активные пакеты: {items}",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "Активных пакетов нет.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Нажмите на пакет, чтобы включить или выключить его отображение."
|
||||
}
|
||||
|
||||
@@ -125,8 +125,24 @@ class AdvertisingCampaignService:
|
||||
)
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
if not squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
if trial_uuid:
|
||||
squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось подобрать сквад для кампании %s: %s",
|
||||
campaign.id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
@@ -135,6 +151,7 @@ class AdvertisingCampaignService:
|
||||
traffic_limit_gb=traffic_limit or 0,
|
||||
device_limit=device_limit,
|
||||
connected_squads=squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Утилиты для синхронизации токена внешней админки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SystemSetting
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ensure_external_admin_token(
|
||||
bot_username: Optional[str],
|
||||
bot_id: Optional[int],
|
||||
) -> Optional[str]:
|
||||
"""Генерирует и сохраняет токен внешней админки, если требуется."""
|
||||
|
||||
username_raw = (bot_username or "").strip()
|
||||
if not username_raw:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует",
|
||||
)
|
||||
return None
|
||||
|
||||
normalized_username = username_raw.lstrip("@").lower()
|
||||
if not normalized_username:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации",
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
token = settings.build_external_admin_token(normalized_username)
|
||||
except Exception as error: # pragma: no cover - защитный блок
|
||||
logger.error("❌ Ошибка генерации токена внешней админки: %s", error)
|
||||
return None
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SystemSetting.key, SystemSetting.value).where(
|
||||
SystemSetting.key.in_(
|
||||
["EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"]
|
||||
)
|
||||
)
|
||||
)
|
||||
rows = dict(result.all())
|
||||
existing_token = rows.get("EXTERNAL_ADMIN_TOKEN")
|
||||
existing_bot_id_raw = rows.get("EXTERNAL_ADMIN_TOKEN_BOT_ID")
|
||||
|
||||
existing_bot_id: Optional[int] = None
|
||||
if existing_bot_id_raw is not None:
|
||||
try:
|
||||
existing_bot_id = int(existing_bot_id_raw)
|
||||
except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений
|
||||
logger.warning(
|
||||
"⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки: %s",
|
||||
existing_bot_id_raw,
|
||||
)
|
||||
|
||||
if existing_token == token and existing_bot_id == bot_id:
|
||||
if settings.get_external_admin_token() != token:
|
||||
settings.EXTERNAL_ADMIN_TOKEN = token
|
||||
if settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id:
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
|
||||
return token
|
||||
|
||||
if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id:
|
||||
logger.error(
|
||||
"❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен %s, текущий %s",
|
||||
existing_bot_id,
|
||||
bot_id,
|
||||
)
|
||||
|
||||
try:
|
||||
await bot_configuration_service.reset_value(
|
||||
session,
|
||||
"EXTERNAL_ADMIN_TOKEN",
|
||||
force=True,
|
||||
)
|
||||
await bot_configuration_service.reset_value(
|
||||
session,
|
||||
"EXTERNAL_ADMIN_TOKEN_BOT_ID",
|
||||
force=True,
|
||||
)
|
||||
await session.commit()
|
||||
logger.warning(
|
||||
"⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота",
|
||||
)
|
||||
except Exception as cleanup_error: # pragma: no cover - защитный блок
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
"❌ Не удалось очистить токен внешней админки после обнаружения подмены: %s",
|
||||
cleanup_error,
|
||||
)
|
||||
finally:
|
||||
settings.EXTERNAL_ADMIN_TOKEN = None
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None
|
||||
|
||||
return None
|
||||
|
||||
updates: list[tuple[str, object]] = []
|
||||
if existing_token != token:
|
||||
updates.append(("EXTERNAL_ADMIN_TOKEN", token))
|
||||
|
||||
if bot_id is not None and existing_bot_id != bot_id:
|
||||
updates.append(("EXTERNAL_ADMIN_TOKEN_BOT_ID", bot_id))
|
||||
|
||||
if not updates:
|
||||
# Токен совпал, но могли отсутствовать значения в настройках приложения
|
||||
if settings.get_external_admin_token() != (existing_token or token):
|
||||
settings.EXTERNAL_ADMIN_TOKEN = existing_token or token
|
||||
if existing_bot_id is not None and (
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id
|
||||
):
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
|
||||
elif (
|
||||
bot_id is not None
|
||||
and settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != bot_id
|
||||
and existing_bot_id is None
|
||||
):
|
||||
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id
|
||||
return existing_token or token
|
||||
|
||||
try:
|
||||
for key, value in updates:
|
||||
await bot_configuration_service.set_value(
|
||||
session,
|
||||
key,
|
||||
value,
|
||||
force=True,
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"✅ Токен внешней админки синхронизирован для @%s",
|
||||
normalized_username,
|
||||
)
|
||||
except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение
|
||||
await session.rollback()
|
||||
logger.warning(
|
||||
"⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа",
|
||||
)
|
||||
return None
|
||||
|
||||
return token
|
||||
except SQLAlchemyError as error:
|
||||
logger.error("❌ Ошибка сохранения токена внешней админки: %s", error)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.faq import (
|
||||
bulk_update_order,
|
||||
create_faq_page,
|
||||
delete_faq_page,
|
||||
get_faq_page_by_id,
|
||||
get_faq_pages,
|
||||
get_faq_setting,
|
||||
set_faq_enabled,
|
||||
update_faq_page,
|
||||
)
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FaqService:
|
||||
MAX_PAGE_LENGTH = 3500
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: str) -> str:
|
||||
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
|
||||
return base_language.split("-")[0].lower()
|
||||
|
||||
@staticmethod
|
||||
def normalize_language(language: str) -> str:
|
||||
return FaqService._normalize_language(language)
|
||||
|
||||
@classmethod
|
||||
async def get_setting(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = True,
|
||||
) -> Optional[FaqSetting]:
|
||||
lang = cls._normalize_language(language)
|
||||
setting = await get_faq_setting(db, lang)
|
||||
|
||||
if setting or not fallback:
|
||||
return setting
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
return await get_faq_setting(db, default_lang)
|
||||
|
||||
return setting
|
||||
|
||||
@classmethod
|
||||
async def is_enabled(cls, db: AsyncSession, language: str) -> bool:
|
||||
pages = await cls.get_pages(db, language)
|
||||
return bool(pages)
|
||||
|
||||
@classmethod
|
||||
async def set_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> FaqSetting:
|
||||
lang = cls._normalize_language(language)
|
||||
return await set_faq_enabled(db, lang, enabled)
|
||||
|
||||
@classmethod
|
||||
async def toggle_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> FaqSetting:
|
||||
lang = cls._normalize_language(language)
|
||||
setting = await get_faq_setting(db, lang)
|
||||
new_status = True
|
||||
if setting:
|
||||
new_status = not setting.is_enabled
|
||||
return await set_faq_enabled(db, lang, new_status)
|
||||
|
||||
@classmethod
|
||||
async def get_pages(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
fallback: bool = True,
|
||||
) -> List[FaqPage]:
|
||||
lang = cls._normalize_language(language)
|
||||
pages = await get_faq_pages(db, lang, include_inactive=include_inactive)
|
||||
|
||||
if pages:
|
||||
setting = await get_faq_setting(db, lang)
|
||||
if setting and not setting.is_enabled and not include_inactive:
|
||||
return []
|
||||
return pages
|
||||
|
||||
if not fallback:
|
||||
return []
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang == default_lang:
|
||||
return []
|
||||
|
||||
fallback_pages = await get_faq_pages(
|
||||
db,
|
||||
default_lang,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
|
||||
if not fallback_pages:
|
||||
return []
|
||||
|
||||
setting = await get_faq_setting(db, default_lang)
|
||||
if setting and not setting.is_enabled and not include_inactive:
|
||||
return []
|
||||
|
||||
return fallback_pages
|
||||
|
||||
@classmethod
|
||||
async def get_page(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
page_id: int,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = True,
|
||||
include_inactive: bool = False,
|
||||
) -> Optional[FaqPage]:
|
||||
page = await get_faq_page_by_id(db, page_id)
|
||||
if not page:
|
||||
return None
|
||||
|
||||
lang = cls._normalize_language(language)
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
|
||||
if not include_inactive and not page.is_active:
|
||||
return None
|
||||
|
||||
if page.language == lang:
|
||||
return page
|
||||
|
||||
if fallback and page.language == default_lang:
|
||||
return page
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def create_page(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
language: str,
|
||||
title: str,
|
||||
content: str,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> FaqPage:
|
||||
lang = cls._normalize_language(language)
|
||||
is_active_value = True if is_active is None else bool(is_active)
|
||||
page = await create_faq_page(
|
||||
db,
|
||||
language=lang,
|
||||
title=title,
|
||||
content=content,
|
||||
display_order=display_order,
|
||||
is_active=is_active_value,
|
||||
)
|
||||
|
||||
setting = await get_faq_setting(db, lang)
|
||||
if not setting:
|
||||
await set_faq_enabled(db, lang, True)
|
||||
|
||||
return page
|
||||
|
||||
@classmethod
|
||||
async def update_page(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
page: FaqPage,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> FaqPage:
|
||||
return await update_faq_page(
|
||||
db,
|
||||
page,
|
||||
title=title,
|
||||
content=content,
|
||||
display_order=display_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def delete_page(cls, db: AsyncSession, page_id: int) -> None:
|
||||
await delete_faq_page(db, page_id)
|
||||
|
||||
@classmethod
|
||||
async def reorder_pages(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
pages: List[FaqPage],
|
||||
) -> None:
|
||||
lang = cls._normalize_language(language)
|
||||
ordered = [page for page in pages if page.language == lang]
|
||||
payload = [(page.id, index + 1) for index, page in enumerate(ordered)]
|
||||
await bulk_update_order(db, payload)
|
||||
|
||||
@staticmethod
|
||||
def split_content_into_pages(
|
||||
content: str,
|
||||
*,
|
||||
max_length: Optional[int] = None,
|
||||
) -> List[str]:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
limit = max_length or FaqService.MAX_PAGE_LENGTH
|
||||
if len(normalized) <= limit:
|
||||
return [normalized]
|
||||
|
||||
paragraphs = [
|
||||
paragraph.strip()
|
||||
for paragraph in normalized.split("\n\n")
|
||||
if paragraph.strip()
|
||||
]
|
||||
|
||||
pages: List[str] = []
|
||||
current = ""
|
||||
|
||||
def flush_current() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
pages.append(current.strip())
|
||||
current = ""
|
||||
|
||||
for paragraph in paragraphs:
|
||||
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
|
||||
if len(candidate) <= limit:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
flush_current()
|
||||
|
||||
if len(paragraph) <= limit:
|
||||
current = paragraph
|
||||
continue
|
||||
|
||||
start = 0
|
||||
while start < len(paragraph):
|
||||
chunk = paragraph[start : start + limit]
|
||||
pages.append(chunk.strip())
|
||||
start += limit
|
||||
|
||||
current = ""
|
||||
|
||||
flush_current()
|
||||
|
||||
if not pages:
|
||||
return [normalized[:limit]]
|
||||
|
||||
return pages
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
MainMenuButton,
|
||||
MainMenuButtonActionType,
|
||||
MainMenuButtonVisibility,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _MainMenuButtonData:
|
||||
text: str
|
||||
action_type: MainMenuButtonActionType
|
||||
action_value: str
|
||||
visibility: MainMenuButtonVisibility
|
||||
is_active: bool
|
||||
display_order: int
|
||||
|
||||
|
||||
class MainMenuButtonService:
|
||||
_cache: List[_MainMenuButtonData] | None = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def invalidate_cache(cls) -> None:
|
||||
cls._cache = None
|
||||
|
||||
@classmethod
|
||||
async def _load_cache(cls, db: AsyncSession) -> List[_MainMenuButtonData]:
|
||||
if cls._cache is not None:
|
||||
return cls._cache
|
||||
|
||||
async with cls._lock:
|
||||
if cls._cache is not None:
|
||||
return cls._cache
|
||||
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).order_by(
|
||||
MainMenuButton.display_order.asc(),
|
||||
MainMenuButton.id.asc(),
|
||||
)
|
||||
)
|
||||
|
||||
items: List[_MainMenuButtonData] = []
|
||||
for record in result.scalars().all():
|
||||
text = (record.text or "").strip()
|
||||
action_value = (record.action_value or "").strip()
|
||||
|
||||
if not text or not action_value:
|
||||
continue
|
||||
|
||||
try:
|
||||
action_type = MainMenuButtonActionType(record.action_type)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
try:
|
||||
visibility = MainMenuButtonVisibility(record.visibility)
|
||||
except ValueError:
|
||||
visibility = MainMenuButtonVisibility.ALL
|
||||
|
||||
items.append(
|
||||
_MainMenuButtonData(
|
||||
text=text,
|
||||
action_type=action_type,
|
||||
action_value=action_value,
|
||||
visibility=visibility,
|
||||
is_active=bool(record.is_active),
|
||||
display_order=int(record.display_order or 0),
|
||||
)
|
||||
)
|
||||
|
||||
cls._cache = items
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
async def get_buttons_for_user(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
is_admin: bool,
|
||||
has_active_subscription: bool,
|
||||
subscription_is_active: bool,
|
||||
) -> list[InlineKeyboardButton]:
|
||||
data = await cls._load_cache(db)
|
||||
has_subscription = bool(has_active_subscription and subscription_is_active)
|
||||
|
||||
buttons: list[InlineKeyboardButton] = []
|
||||
for item in data:
|
||||
if not item.is_active:
|
||||
continue
|
||||
|
||||
if item.visibility == MainMenuButtonVisibility.ADMINS and not is_admin:
|
||||
continue
|
||||
|
||||
if item.visibility == MainMenuButtonVisibility.SUBSCRIBERS and not has_subscription:
|
||||
continue
|
||||
|
||||
button = cls._build_button(item)
|
||||
if button:
|
||||
buttons.append(button)
|
||||
|
||||
return buttons
|
||||
|
||||
@staticmethod
|
||||
def _build_button(item: _MainMenuButtonData) -> InlineKeyboardButton | None:
|
||||
if item.action_type == MainMenuButtonActionType.URL:
|
||||
return InlineKeyboardButton(text=item.text, url=item.action_value)
|
||||
|
||||
if item.action_type == MainMenuButtonActionType.MINI_APP:
|
||||
return InlineKeyboardButton(
|
||||
text=item.text,
|
||||
web_app=types.WebAppInfo(url=item.action_value),
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -15,8 +15,10 @@ from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.discount_offer import (
|
||||
deactivate_expired_offers,
|
||||
get_latest_claimed_offer_for_user,
|
||||
upsert_discount_offer,
|
||||
)
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.database.crud.notification import (
|
||||
clear_notification_by_type,
|
||||
notification_sent,
|
||||
@@ -34,12 +36,15 @@ from app.database.crud.user import (
|
||||
get_inactive_users,
|
||||
get_user_by_id,
|
||||
subtract_user_balance,
|
||||
cleanup_expired_promo_offer_discounts,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPIError,
|
||||
@@ -179,6 +184,17 @@ class MonitoringService:
|
||||
if expired_offers:
|
||||
logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений")
|
||||
|
||||
expired_active_discounts = await cleanup_expired_promo_offer_discounts(db)
|
||||
if expired_active_discounts:
|
||||
logger.info(
|
||||
"🧹 Сброшено %s активных скидок промо-предложений с истекшим сроком",
|
||||
expired_active_discounts,
|
||||
)
|
||||
|
||||
cleaned_test_access = await promo_offer_service.cleanup_expired_test_access(db)
|
||||
if cleaned_test_access:
|
||||
logger.info(f"🧹 Отозвано {cleaned_test_access} истекших тестовых доступов к сквадам")
|
||||
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
@@ -676,15 +692,15 @@ class MonitoringService:
|
||||
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"):
|
||||
percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
notification_type="expired_discount_wave2",
|
||||
discount_percent=percent,
|
||||
bonus_amount_kopeks=bonus_amount,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=valid_hours,
|
||||
effect_type="percent_discount",
|
||||
)
|
||||
success = await self._send_expired_discount_notification(
|
||||
user,
|
||||
@@ -693,7 +709,6 @@ class MonitoringService:
|
||||
offer.expires_at,
|
||||
offer.id,
|
||||
"second",
|
||||
bonus_amount,
|
||||
)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, "expired_discount_wave2")
|
||||
@@ -706,15 +721,15 @@ class MonitoringService:
|
||||
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"):
|
||||
percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
bonus_amount = settings.PRICE_30_DAYS * percent // 100
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
notification_type="expired_discount_wave3",
|
||||
discount_percent=percent,
|
||||
bonus_amount_kopeks=bonus_amount,
|
||||
bonus_amount_kopeks=0,
|
||||
valid_hours=valid_hours,
|
||||
effect_type="percent_discount",
|
||||
)
|
||||
success = await self._send_expired_discount_notification(
|
||||
user,
|
||||
@@ -723,7 +738,6 @@ class MonitoringService:
|
||||
offer.expires_at,
|
||||
offer.id,
|
||||
"third",
|
||||
bonus_amount,
|
||||
trigger_days=trigger_days,
|
||||
)
|
||||
if success:
|
||||
@@ -777,6 +791,85 @@ class MonitoringService:
|
||||
|
||||
return subscriptions
|
||||
|
||||
@staticmethod
|
||||
def _get_user_promo_offer_discount_percent(user: Optional[User]) -> int:
|
||||
if not user:
|
||||
return 0
|
||||
|
||||
try:
|
||||
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||||
if expires_at and expires_at <= datetime.utcnow():
|
||||
return 0
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
@staticmethod
|
||||
async def _consume_user_promo_offer_discount(db: AsyncSession, user: User) -> None:
|
||||
percent = MonitoringService._get_user_promo_offer_discount_percent(user)
|
||||
if percent <= 0:
|
||||
return
|
||||
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
log_payload = {
|
||||
"offer_id": None,
|
||||
"percent": percent,
|
||||
"source": source,
|
||||
"effect_type": None,
|
||||
}
|
||||
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception as lookup_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to resolve latest claimed promo offer for user %s: %s",
|
||||
user.id,
|
||||
lookup_error,
|
||||
)
|
||||
offer = None
|
||||
|
||||
if offer:
|
||||
log_payload["offer_id"] = offer.id
|
||||
log_payload["effect_type"] = offer.effect_type
|
||||
if not log_payload["percent"] and offer.discount_percent:
|
||||
log_payload["percent"] = offer.discount_percent
|
||||
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
offer_id=log_payload.get("offer_id"),
|
||||
action="consumed",
|
||||
source=log_payload.get("source"),
|
||||
percent=log_payload.get("percent"),
|
||||
effect_type=log_payload.get("effect_type"),
|
||||
details={"reason": "autopay_consumed"},
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer autopay log for user %s: %s",
|
||||
user.id,
|
||||
log_error,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer autopay log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
async def _process_autopayments(self, db: AsyncSession):
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
@@ -809,17 +902,26 @@ class MonitoringService:
|
||||
continue
|
||||
|
||||
renewal_cost = settings.PRICE_30_DAYS
|
||||
|
||||
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
|
||||
charge_amount = renewal_cost
|
||||
promo_discount_value = 0
|
||||
|
||||
if renewal_cost > 0 and promo_discount_percent > 0:
|
||||
charge_amount, promo_discount_value = apply_percentage_discount(
|
||||
renewal_cost,
|
||||
promo_discount_percent,
|
||||
)
|
||||
|
||||
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
|
||||
if autopay_key in self._notified_users:
|
||||
continue
|
||||
|
||||
if user.balance_kopeks >= renewal_cost:
|
||||
|
||||
if user.balance_kopeks >= charge_amount:
|
||||
success = await subtract_user_balance(
|
||||
db, user, renewal_cost,
|
||||
db, user, charge_amount,
|
||||
"Автопродление подписки"
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
await extend_subscription(db, subscription, 30)
|
||||
await self.subscription_service.update_remnawave_user(
|
||||
@@ -828,22 +930,30 @@ class MonitoringService:
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason="автопродление подписки",
|
||||
)
|
||||
|
||||
|
||||
if promo_discount_value > 0:
|
||||
await self._consume_user_promo_offer_discount(db, user)
|
||||
|
||||
if self.bot:
|
||||
await self._send_autopay_success_notification(user, renewal_cost, 30)
|
||||
|
||||
await self._send_autopay_success_notification(user, charge_amount, 30)
|
||||
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно")
|
||||
logger.info(
|
||||
"💳 Автопродление подписки пользователя %s успешно (списано %s, скидка %s%%)",
|
||||
user.telegram_id,
|
||||
charge_amount,
|
||||
promo_discount_percent,
|
||||
)
|
||||
else:
|
||||
failed_count += 1
|
||||
if self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
|
||||
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
|
||||
else:
|
||||
failed_count += 1
|
||||
if self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
|
||||
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
|
||||
|
||||
if processed_count > 0 or failed_count > 0:
|
||||
@@ -1186,7 +1296,6 @@ class MonitoringService:
|
||||
expires_at: datetime,
|
||||
offer_id: int,
|
||||
wave: str,
|
||||
bonus_amount: int,
|
||||
trigger_days: int = None,
|
||||
) -> bool:
|
||||
try:
|
||||
@@ -1197,8 +1306,8 @@ class MonitoringService:
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
|
||||
(
|
||||
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
|
||||
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
|
||||
"Предложение действует до {expires_at}."
|
||||
"Активируйте предложение, чтобы получить дополнительную скидку. "
|
||||
"Она суммируется с вашей промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -1206,14 +1315,13 @@ class MonitoringService:
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
|
||||
(
|
||||
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
|
||||
"Скидка действует до {expires_at}."
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
|
||||
"Она суммируется с промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
bonus=settings.format_price(bonus_amount),
|
||||
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
|
||||
trigger_days=trigger_days or "",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.privacy_policy import (
|
||||
get_privacy_policy,
|
||||
set_privacy_policy_enabled,
|
||||
upsert_privacy_policy,
|
||||
)
|
||||
from app.database.models import PrivacyPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PrivacyPolicyService:
|
||||
"""Utility helpers around privacy policy storage and presentation."""
|
||||
|
||||
MAX_PAGE_LENGTH = 3500
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: str) -> str:
|
||||
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
|
||||
return base_language.split("-")[0].lower()
|
||||
|
||||
@staticmethod
|
||||
def normalize_language(language: str) -> str:
|
||||
return PrivacyPolicyService._normalize_language(language)
|
||||
|
||||
@classmethod
|
||||
async def get_policy(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = False,
|
||||
) -> Optional[PrivacyPolicy]:
|
||||
lang = cls._normalize_language(language)
|
||||
policy = await get_privacy_policy(db, lang)
|
||||
|
||||
if policy or not fallback:
|
||||
return policy
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
return await get_privacy_policy(db, default_lang)
|
||||
|
||||
return policy
|
||||
|
||||
@classmethod
|
||||
async def get_active_policy(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> Optional[PrivacyPolicy]:
|
||||
lang = cls._normalize_language(language)
|
||||
policy = await get_privacy_policy(db, lang)
|
||||
|
||||
if policy and policy.is_enabled and policy.content.strip():
|
||||
return policy
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
fallback_policy = await get_privacy_policy(db, default_lang)
|
||||
if fallback_policy and fallback_policy.is_enabled and fallback_policy.content.strip():
|
||||
return fallback_policy
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def is_policy_enabled(cls, db: AsyncSession, language: str) -> bool:
|
||||
policy = await cls.get_active_policy(db, language)
|
||||
return policy is not None
|
||||
|
||||
@classmethod
|
||||
async def save_policy(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
) -> PrivacyPolicy:
|
||||
lang = cls._normalize_language(language)
|
||||
enable_if_new = True
|
||||
policy = await upsert_privacy_policy(
|
||||
db,
|
||||
lang,
|
||||
content,
|
||||
enable_if_new=enable_if_new,
|
||||
)
|
||||
logger.info("✅ Политика конфиденциальности обновлена для языка %s", lang)
|
||||
return policy
|
||||
|
||||
@classmethod
|
||||
async def set_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PrivacyPolicy:
|
||||
lang = cls._normalize_language(language)
|
||||
return await set_privacy_policy_enabled(db, lang, enabled)
|
||||
|
||||
@classmethod
|
||||
async def toggle_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> PrivacyPolicy:
|
||||
lang = cls._normalize_language(language)
|
||||
policy = await get_privacy_policy(db, lang)
|
||||
|
||||
if policy:
|
||||
new_status = not policy.is_enabled
|
||||
else:
|
||||
new_status = True
|
||||
|
||||
return await set_privacy_policy_enabled(db, lang, new_status)
|
||||
|
||||
@staticmethod
|
||||
def split_content_into_pages(
|
||||
content: str,
|
||||
*,
|
||||
max_length: int = None,
|
||||
) -> List[str]:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
max_len = max_length or PrivacyPolicyService.MAX_PAGE_LENGTH
|
||||
|
||||
if len(normalized) <= max_len:
|
||||
return [normalized]
|
||||
|
||||
paragraphs = [
|
||||
paragraph.strip()
|
||||
for paragraph in normalized.split("\n\n")
|
||||
if paragraph.strip()
|
||||
]
|
||||
|
||||
pages: List[str] = []
|
||||
current = ""
|
||||
|
||||
def flush_current() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
pages.append(current.strip())
|
||||
current = ""
|
||||
|
||||
for paragraph in paragraphs:
|
||||
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
|
||||
if len(candidate) <= max_len:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
flush_current()
|
||||
|
||||
if len(paragraph) <= max_len:
|
||||
current = paragraph
|
||||
continue
|
||||
|
||||
start_index = 0
|
||||
while start_index < len(paragraph):
|
||||
chunk = paragraph[start_index:start_index + max_len]
|
||||
pages.append(chunk.strip())
|
||||
start_index += max_len
|
||||
|
||||
current = ""
|
||||
|
||||
flush_current()
|
||||
|
||||
if not pages:
|
||||
return [normalized[:max_len]]
|
||||
|
||||
return pages
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
DiscountOffer,
|
||||
Subscription,
|
||||
SubscriptionTemporaryAccess,
|
||||
User,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PromoOfferService:
|
||||
def __init__(self) -> None:
|
||||
self.subscription_service = SubscriptionService()
|
||||
|
||||
async def grant_test_access(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
offer: DiscountOffer,
|
||||
) -> Tuple[bool, Optional[List[str]], Optional[datetime], str]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription:
|
||||
return False, None, None, "subscription_missing"
|
||||
|
||||
payload = offer.extra_data or {}
|
||||
raw_squads = payload.get("test_squad_uuids") or payload.get("squads") or []
|
||||
if isinstance(raw_squads, str):
|
||||
candidates = [raw_squads]
|
||||
else:
|
||||
try:
|
||||
candidates = list(raw_squads)
|
||||
except TypeError:
|
||||
candidates = []
|
||||
|
||||
squad_uuids: Sequence[str] = [str(item) for item in candidates if item]
|
||||
if not squad_uuids:
|
||||
return False, None, None, "squads_missing"
|
||||
|
||||
squad_uuids = list(dict.fromkeys(squad_uuids))
|
||||
|
||||
connected = {str(item) for item in subscription.connected_squads or []}
|
||||
if squad_uuids and set(squad_uuids).issubset(connected):
|
||||
return False, None, None, "already_connected"
|
||||
|
||||
try:
|
||||
duration_hours = int(payload.get("test_duration_hours") or payload.get("duration_hours") or 24)
|
||||
except (TypeError, ValueError):
|
||||
duration_hours = 24
|
||||
|
||||
if duration_hours <= 0:
|
||||
duration_hours = 24
|
||||
|
||||
now = datetime.utcnow()
|
||||
expires_at = now + timedelta(hours=duration_hours)
|
||||
|
||||
original_connected = set(connected)
|
||||
newly_added: List[str] = []
|
||||
changes_made = False
|
||||
|
||||
for squad_uuid in squad_uuids:
|
||||
normalized_uuid = str(squad_uuid)
|
||||
existing_result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.where(
|
||||
SubscriptionTemporaryAccess.offer_id == offer.id,
|
||||
SubscriptionTemporaryAccess.squad_uuid == normalized_uuid,
|
||||
)
|
||||
.order_by(SubscriptionTemporaryAccess.id.desc())
|
||||
)
|
||||
existing_access = existing_result.scalars().first()
|
||||
if existing_access and existing_access.is_active:
|
||||
if existing_access.expires_at < expires_at:
|
||||
existing_access.expires_at = expires_at
|
||||
changes_made = True
|
||||
continue
|
||||
|
||||
was_already_connected = normalized_uuid in connected
|
||||
if not was_already_connected:
|
||||
connected.add(normalized_uuid)
|
||||
newly_added.append(normalized_uuid)
|
||||
changes_made = True
|
||||
|
||||
access_entry = SubscriptionTemporaryAccess(
|
||||
subscription_id=subscription.id,
|
||||
offer_id=offer.id,
|
||||
squad_uuid=normalized_uuid,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
was_already_connected=was_already_connected,
|
||||
)
|
||||
db.add(access_entry)
|
||||
changes_made = True
|
||||
|
||||
connected_changed = connected != original_connected
|
||||
|
||||
if newly_added:
|
||||
subscription.connected_squads = list(connected)
|
||||
subscription.updated_at = now
|
||||
changes_made = True
|
||||
|
||||
if connected_changed:
|
||||
remnawave_user = await self.subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
)
|
||||
if remnawave_user is None:
|
||||
await db.rollback()
|
||||
await db.refresh(subscription)
|
||||
logger.error(
|
||||
"Не удалось синхронизировать временный доступ подписки %s с RemnaWave",
|
||||
subscription.id,
|
||||
)
|
||||
return False, None, None, "remnawave_sync_failed"
|
||||
|
||||
await db.refresh(subscription)
|
||||
elif changes_made:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
return True, newly_added, expires_at, "ok"
|
||||
|
||||
async def cleanup_expired_test_access(self, db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.options(
|
||||
selectinload(SubscriptionTemporaryAccess.subscription),
|
||||
selectinload(SubscriptionTemporaryAccess.offer),
|
||||
)
|
||||
.where(
|
||||
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
|
||||
SubscriptionTemporaryAccess.expires_at <= now,
|
||||
)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
if not entries:
|
||||
return 0
|
||||
|
||||
subscriptions_updates: dict[int, Tuple[Subscription, set[str]]] = {}
|
||||
log_payloads: List[Dict[str, object]] = []
|
||||
|
||||
for entry in entries:
|
||||
entry.is_active = False
|
||||
entry.deactivated_at = now
|
||||
subscription = entry.subscription
|
||||
if not subscription:
|
||||
continue
|
||||
|
||||
bucket = subscriptions_updates.setdefault(subscription.id, (subscription, set()))
|
||||
if not entry.was_already_connected:
|
||||
bucket[1].add(entry.squad_uuid)
|
||||
|
||||
user_id = subscription.user_id
|
||||
if user_id:
|
||||
offer = entry.offer
|
||||
log_payloads.append(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"offer_id": entry.offer_id,
|
||||
"source": getattr(offer, "notification_type", None),
|
||||
"percent": getattr(offer, "discount_percent", None),
|
||||
"effect_type": getattr(offer, "effect_type", "test_access"),
|
||||
"details": {
|
||||
"reason": "test_access_expired",
|
||||
"squad_uuid": entry.squad_uuid,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for subscription, squads_to_remove in subscriptions_updates.values():
|
||||
if not squads_to_remove:
|
||||
continue
|
||||
current = set(subscription.connected_squads or [])
|
||||
updated = current.difference(squads_to_remove)
|
||||
if updated != current:
|
||||
subscription.connected_squads = list(updated)
|
||||
subscription.updated_at = now
|
||||
try:
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Ошибка обновления Remnawave при отзыве тестового доступа подписки %s: %s",
|
||||
subscription.id,
|
||||
exc,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
for payload in log_payloads:
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=payload["user_id"],
|
||||
offer_id=payload.get("offer_id"),
|
||||
action="disabled",
|
||||
source=payload.get("source"),
|
||||
percent=payload.get("percent"),
|
||||
effect_type=payload.get("effect_type"),
|
||||
details=payload.get("details"),
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer test access disable log for user %s: %s",
|
||||
payload.get("user_id"),
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer test access log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
return len(entries)
|
||||
|
||||
|
||||
promo_offer_service = PromoOfferService()
|
||||
@@ -113,16 +113,32 @@ class PromoCodeService:
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
|
||||
trial_squads = []
|
||||
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось подобрать сквад для подписки по промокоду %s: %s",
|
||||
promocode.code,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=promocode.subscription_days,
|
||||
traffic_limit_gb=0,
|
||||
traffic_limit_gb=0,
|
||||
device_limit=1,
|
||||
connected_squads=trial_squads
|
||||
connected_squads=trial_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, new_subscription)
|
||||
@@ -140,9 +156,9 @@ class PromoCodeService:
|
||||
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
|
||||
|
||||
trial_subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import logging
|
||||
from html.parser import HTMLParser
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.public_offer import (
|
||||
get_public_offer,
|
||||
set_public_offer_enabled,
|
||||
upsert_public_offer,
|
||||
)
|
||||
from app.database.models import PublicOffer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PublicOfferService:
|
||||
"""Helpers for managing the public offer text and visibility."""
|
||||
|
||||
MAX_PAGE_LENGTH = 3500
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: str) -> str:
|
||||
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
|
||||
return base_language.split("-")[0].lower()
|
||||
|
||||
@staticmethod
|
||||
def normalize_language(language: str) -> str:
|
||||
return PublicOfferService._normalize_language(language)
|
||||
|
||||
@classmethod
|
||||
async def get_offer(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
fallback: bool = False,
|
||||
) -> Optional[PublicOffer]:
|
||||
lang = cls._normalize_language(language)
|
||||
offer = await get_public_offer(db, lang)
|
||||
|
||||
if offer or not fallback:
|
||||
return offer
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
return await get_public_offer(db, default_lang)
|
||||
|
||||
return offer
|
||||
|
||||
@classmethod
|
||||
async def get_active_offer(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> Optional[PublicOffer]:
|
||||
lang = cls._normalize_language(language)
|
||||
offer = await get_public_offer(db, lang)
|
||||
|
||||
if offer:
|
||||
if offer.is_enabled and offer.content.strip():
|
||||
return offer
|
||||
|
||||
if not offer.is_enabled:
|
||||
return None
|
||||
|
||||
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
|
||||
if lang != default_lang:
|
||||
fallback_offer = await get_public_offer(db, default_lang)
|
||||
if fallback_offer and fallback_offer.is_enabled and fallback_offer.content.strip():
|
||||
return fallback_offer
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def is_offer_enabled(cls, db: AsyncSession, language: str) -> bool:
|
||||
offer = await cls.get_active_offer(db, language)
|
||||
return offer is not None
|
||||
|
||||
@classmethod
|
||||
async def save_offer(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
) -> PublicOffer:
|
||||
lang = cls._normalize_language(language)
|
||||
enable_if_new = True
|
||||
offer = await upsert_public_offer(
|
||||
db,
|
||||
lang,
|
||||
content,
|
||||
enable_if_new=enable_if_new,
|
||||
)
|
||||
logger.info("✅ Публичная оферта обновлена для языка %s", lang)
|
||||
return offer
|
||||
|
||||
@classmethod
|
||||
async def set_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PublicOffer:
|
||||
lang = cls._normalize_language(language)
|
||||
return await set_public_offer_enabled(db, lang, enabled)
|
||||
|
||||
@classmethod
|
||||
async def toggle_enabled(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
) -> PublicOffer:
|
||||
lang = cls._normalize_language(language)
|
||||
offer = await get_public_offer(db, lang)
|
||||
|
||||
if offer:
|
||||
new_status = not offer.is_enabled
|
||||
else:
|
||||
new_status = True
|
||||
|
||||
return await set_public_offer_enabled(db, lang, new_status)
|
||||
|
||||
class _RichTextPaginator(HTMLParser):
|
||||
"""Split HTML-like text into Telegram-safe chunks."""
|
||||
|
||||
SELF_CLOSING_TAGS = {"br", "hr", "img", "input", "meta", "link"}
|
||||
|
||||
def __init__(self, max_len: int) -> None:
|
||||
super().__init__(convert_charrefs=False)
|
||||
self.max_len = max_len
|
||||
self.pages: List[str] = []
|
||||
self.current_parts: List[str] = []
|
||||
self.current_length = 0
|
||||
self.open_stack: List[Tuple[str, str, int]] = []
|
||||
self.closing_length = 0
|
||||
self.needs_prefix = False
|
||||
self.prefix_length = 0
|
||||
|
||||
def _closing_sequence(self) -> str:
|
||||
return "".join(f"</{name}>" for name, _, _ in reversed(self.open_stack))
|
||||
|
||||
def _ensure_prefix(self) -> None:
|
||||
if not self.needs_prefix:
|
||||
return
|
||||
|
||||
prefix = "".join(token for _, token, _ in self.open_stack)
|
||||
if prefix:
|
||||
self.current_parts.append(prefix)
|
||||
self.current_length += len(prefix)
|
||||
self.needs_prefix = False
|
||||
|
||||
def _flush(self) -> None:
|
||||
if not self.current_parts and not self.closing_length:
|
||||
return
|
||||
|
||||
content = "".join(self.current_parts)
|
||||
closing_tags = self._closing_sequence()
|
||||
page = (content + closing_tags).strip()
|
||||
if page:
|
||||
self.pages.append(page)
|
||||
|
||||
self.current_parts = []
|
||||
self.current_length = 0
|
||||
if self.prefix_length + self.closing_length > self.max_len:
|
||||
self.open_stack = []
|
||||
self.closing_length = 0
|
||||
self.prefix_length = 0
|
||||
self.needs_prefix = False
|
||||
else:
|
||||
self.needs_prefix = bool(self.open_stack)
|
||||
|
||||
@staticmethod
|
||||
def _format_attrs(attrs: List[Tuple[str, Optional[str]]]) -> str:
|
||||
parts = []
|
||||
for name, value in attrs:
|
||||
if value is None:
|
||||
parts.append(f" {name}")
|
||||
else:
|
||||
parts.append(f" {name}=\"{value}\"")
|
||||
return "".join(parts)
|
||||
|
||||
def _append_token(self, token: str) -> None:
|
||||
while True:
|
||||
self._ensure_prefix()
|
||||
if self.current_length + len(token) + self.closing_length <= self.max_len:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
break
|
||||
|
||||
self._flush()
|
||||
|
||||
if len(token) > self.max_len and not self.current_parts:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
break
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not data:
|
||||
return
|
||||
|
||||
remaining = data
|
||||
while remaining:
|
||||
self._ensure_prefix()
|
||||
available = self.max_len - (self.current_length + self.closing_length)
|
||||
if available <= 0:
|
||||
self._flush()
|
||||
continue
|
||||
|
||||
piece = remaining[:available]
|
||||
self.current_parts.append(piece)
|
||||
self.current_length += len(piece)
|
||||
remaining = remaining[available:]
|
||||
|
||||
if remaining:
|
||||
self._flush()
|
||||
|
||||
def handle_entityref(self, name: str) -> None:
|
||||
self.handle_data(f"&{name};")
|
||||
|
||||
def handle_charref(self, name: str) -> None:
|
||||
self.handle_data(f"&#{name};")
|
||||
|
||||
def _handle_self_closing(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
token = f"<{tag}{self._format_attrs(attrs)}/>"
|
||||
self._append_token(token)
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
if tag in self.SELF_CLOSING_TAGS:
|
||||
self._handle_self_closing(tag, attrs)
|
||||
return
|
||||
|
||||
token = f"<{tag}{self._format_attrs(attrs)}>"
|
||||
closing_token = f"</{tag}>"
|
||||
closing_len = len(closing_token)
|
||||
|
||||
while True:
|
||||
self._ensure_prefix()
|
||||
projected_length = self.current_length + len(token) + self.closing_length + closing_len
|
||||
if projected_length <= self.max_len:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
self.open_stack.append((tag, token, closing_len))
|
||||
self.closing_length += closing_len
|
||||
self.prefix_length += len(token)
|
||||
break
|
||||
|
||||
self._flush()
|
||||
|
||||
if len(token) + closing_len > self.max_len and not self.current_parts:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
self.open_stack.append((tag, token, closing_len))
|
||||
self.closing_length += closing_len
|
||||
self.prefix_length += len(token)
|
||||
break
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
token = f"</{tag}>"
|
||||
closing_len_reduction = 0
|
||||
index_to_remove = None
|
||||
for index in range(len(self.open_stack) - 1, -1, -1):
|
||||
if self.open_stack[index][0] == tag:
|
||||
closing_len_reduction = self.open_stack[index][2]
|
||||
index_to_remove = index
|
||||
break
|
||||
|
||||
while True:
|
||||
self._ensure_prefix()
|
||||
projected_closing_length = self.closing_length - closing_len_reduction
|
||||
if projected_closing_length < 0:
|
||||
projected_closing_length = 0
|
||||
|
||||
projected_total = self.current_length + len(token) + projected_closing_length
|
||||
if projected_total <= self.max_len or not self.current_parts:
|
||||
self.current_parts.append(token)
|
||||
self.current_length += len(token)
|
||||
if index_to_remove is not None:
|
||||
removed_tag = self.open_stack.pop(index_to_remove)
|
||||
self.closing_length -= closing_len_reduction
|
||||
self.prefix_length -= len(removed_tag[1])
|
||||
break
|
||||
|
||||
self._flush()
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||
self._handle_self_closing(tag, attrs)
|
||||
|
||||
def finalize(self) -> List[str]:
|
||||
self._flush()
|
||||
return self.pages
|
||||
|
||||
@classmethod
|
||||
def _split_rich_paragraph(cls, paragraph: str, max_len: int) -> List[str]:
|
||||
if len(paragraph) <= max_len:
|
||||
return [paragraph]
|
||||
|
||||
paginator = cls._RichTextPaginator(max_len)
|
||||
paginator.feed(paragraph)
|
||||
paginator.close()
|
||||
pages = paginator.finalize()
|
||||
return pages or [paragraph]
|
||||
|
||||
@classmethod
|
||||
def split_content_into_pages(
|
||||
cls,
|
||||
content: str,
|
||||
*,
|
||||
max_length: int = None,
|
||||
) -> List[str]:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
normalized = content.replace("\r\n", "\n").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
max_len = max_length or cls.MAX_PAGE_LENGTH
|
||||
|
||||
if len(normalized) <= max_len:
|
||||
return [normalized]
|
||||
|
||||
paragraphs = [
|
||||
paragraph.strip()
|
||||
for paragraph in normalized.split("\n\n")
|
||||
if paragraph.strip()
|
||||
]
|
||||
|
||||
pages: List[str] = []
|
||||
current = ""
|
||||
|
||||
def flush_current() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
pages.append(current.strip())
|
||||
current = ""
|
||||
|
||||
for paragraph in paragraphs:
|
||||
segments = cls._split_rich_paragraph(paragraph, max_len)
|
||||
for segment in segments:
|
||||
segment = segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
|
||||
candidate = f"{current}\n\n{segment}".strip() if current else segment
|
||||
if len(candidate) <= max_len:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
flush_current()
|
||||
current = segment
|
||||
|
||||
flush_current()
|
||||
|
||||
if not pages:
|
||||
return [normalized[:max_len]]
|
||||
|
||||
return pages
|
||||
@@ -1,18 +1,25 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Dict, List, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
|
||||
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
|
||||
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, update_subscription_usage
|
||||
from app.database.crud.subscription import (
|
||||
get_subscription_by_user_id,
|
||||
update_subscription_usage,
|
||||
decrement_subscription_server_counts,
|
||||
)
|
||||
from app.database.models import (
|
||||
User, SubscriptionServer, Transaction, ReferralEarning,
|
||||
PromoCodeUse, SubscriptionStatus
|
||||
@@ -34,6 +41,16 @@ class RemnaWaveService:
|
||||
|
||||
self._config_error: Optional[str] = None
|
||||
|
||||
tz_name = os.getenv("TZ", "UTC")
|
||||
try:
|
||||
self._panel_timezone = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось загрузить временную зону '%s'. Используется UTC.",
|
||||
tz_name,
|
||||
)
|
||||
self._panel_timezone = ZoneInfo("UTC")
|
||||
|
||||
if not base_url:
|
||||
self._config_error = "REMNAWAVE_API_URL не настроен"
|
||||
elif not api_key:
|
||||
@@ -72,33 +89,41 @@ class RemnaWaveService:
|
||||
async with self.api as api:
|
||||
yield api
|
||||
|
||||
def _now_in_panel_timezone(self) -> datetime:
|
||||
"""Возвращает текущее время без часового пояса в зоне панели."""
|
||||
return datetime.now(self._panel_timezone).replace(tzinfo=None)
|
||||
|
||||
def _parse_remnawave_date(self, date_str: str) -> datetime:
|
||||
if not date_str:
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
return self._now_in_panel_timezone() + timedelta(days=30)
|
||||
|
||||
try:
|
||||
|
||||
|
||||
cleaned_date = date_str.strip()
|
||||
|
||||
|
||||
if cleaned_date.endswith('Z'):
|
||||
cleaned_date = cleaned_date[:-1] + '+00:00'
|
||||
|
||||
|
||||
if '+00:00+00:00' in cleaned_date:
|
||||
cleaned_date = cleaned_date.replace('+00:00+00:00', '+00:00')
|
||||
|
||||
|
||||
cleaned_date = re.sub(r'(\+\d{2}:\d{2})\+\d{2}:\d{2}$', r'\1', cleaned_date)
|
||||
|
||||
|
||||
parsed_date = datetime.fromisoformat(cleaned_date)
|
||||
|
||||
|
||||
if parsed_date.tzinfo is not None:
|
||||
parsed_date = parsed_date.replace(tzinfo=None)
|
||||
|
||||
logger.debug(f"Успешно распарсена дата: {date_str} -> {parsed_date}")
|
||||
return parsed_date
|
||||
|
||||
localized = parsed_date.astimezone(self._panel_timezone)
|
||||
else:
|
||||
localized = parsed_date.replace(tzinfo=self._panel_timezone)
|
||||
|
||||
localized_naive = localized.replace(tzinfo=None)
|
||||
|
||||
logger.debug(f"Успешно распарсена дата: {date_str} -> {localized_naive}")
|
||||
return localized_naive
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.")
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
return self._now_in_panel_timezone() + timedelta(days=30)
|
||||
|
||||
async def get_system_statistics(self) -> Dict[str, Any]:
|
||||
try:
|
||||
@@ -600,7 +625,9 @@ class RemnaWaveService:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from app.database.models import SubscriptionServer
|
||||
|
||||
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == subscription.id
|
||||
@@ -652,7 +679,7 @@ class RemnaWaveService:
|
||||
expire_at = self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
panel_status = panel_user.get('status', 'ACTIVE')
|
||||
current_time = datetime.utcnow()
|
||||
current_time = self._now_in_panel_timezone()
|
||||
|
||||
if panel_status == 'ACTIVE' and expire_at > current_time:
|
||||
status = SubscriptionStatus.ACTIVE
|
||||
@@ -707,7 +734,7 @@ class RemnaWaveService:
|
||||
user_id=user.id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=False,
|
||||
end_date=datetime.utcnow() + timedelta(days=30),
|
||||
end_date=self._now_in_panel_timezone() + timedelta(days=30),
|
||||
traffic_limit_gb=0,
|
||||
traffic_used_gb=0.0,
|
||||
device_limit=1,
|
||||
@@ -744,7 +771,7 @@ class RemnaWaveService:
|
||||
subscription.end_date = expire_at
|
||||
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
current_time = self._now_in_panel_timezone()
|
||||
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
|
||||
new_status = SubscriptionStatus.ACTIVE.value
|
||||
elif subscription.end_date <= current_time:
|
||||
@@ -1116,6 +1143,8 @@ class RemnaWaveService:
|
||||
)
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
@@ -1150,22 +1179,22 @@ class RemnaWaveService:
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.used_promocodes = 0
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = self._now_in_panel_timezone()
|
||||
|
||||
if user.subscription:
|
||||
user.subscription.status = SubscriptionStatus.DISABLED.value
|
||||
user.subscription.is_trial = True
|
||||
user.subscription.end_date = datetime.utcnow()
|
||||
user.subscription.end_date = self._now_in_panel_timezone()
|
||||
user.subscription.traffic_limit_gb = 0
|
||||
user.subscription.traffic_used_gb = 0.0
|
||||
user.subscription.device_limit = 1
|
||||
user.subscription.connected_squads = []
|
||||
user.subscription.autopay_enabled = False
|
||||
user.subscription.autopay_days_before = 3
|
||||
user.subscription.autopay_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
user.subscription.remnawave_short_uuid = None
|
||||
user.subscription.subscription_url = ""
|
||||
user.subscription.subscription_crypto_link = ""
|
||||
user.subscription.updated_at = datetime.utcnow()
|
||||
user.subscription.updated_at = self._now_in_panel_timezone()
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1334,7 +1363,7 @@ class RemnaWaveService:
|
||||
user = subscription.user
|
||||
issues_fixed = 0
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
current_time = self._now_in_panel_timezone()
|
||||
if subscription.end_date <= current_time and subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
logger.info(f"🔧 Исправляем статус просроченной подписки {user.telegram_id}")
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
|
||||
@@ -78,31 +78,56 @@ def get_traffic_reset_strategy():
|
||||
class SubscriptionService:
|
||||
|
||||
def __init__(self):
|
||||
self._config_error: Optional[str] = None
|
||||
self.api: Optional[RemnaWaveAPI] = None
|
||||
self._last_config_signature: Optional[Tuple[str, ...]] = None
|
||||
|
||||
self._refresh_configuration()
|
||||
|
||||
def _refresh_configuration(self) -> None:
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
base_url = (auth_params.get("base_url") or "").strip()
|
||||
api_key = (auth_params.get("api_key") or "").strip()
|
||||
secret_key = (auth_params.get("secret_key") or "").strip() or None
|
||||
username = (auth_params.get("username") or "").strip() or None
|
||||
password = (auth_params.get("password") or "").strip() or None
|
||||
auth_type = (auth_params.get("auth_type") or "").strip() or None
|
||||
|
||||
self._config_error: Optional[str] = None
|
||||
config_signature = (
|
||||
base_url,
|
||||
api_key,
|
||||
secret_key or "",
|
||||
username or "",
|
||||
password or "",
|
||||
auth_type or "",
|
||||
)
|
||||
|
||||
if config_signature == self._last_config_signature:
|
||||
return
|
||||
|
||||
if not base_url:
|
||||
self._config_error = "REMNAWAVE_API_URL не настроен"
|
||||
self.api = None
|
||||
elif not api_key:
|
||||
self._config_error = "REMNAWAVE_API_KEY не настроен"
|
||||
self.api = None
|
||||
else:
|
||||
self._config_error = None
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=secret_key,
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
self._last_config_signature = config_signature
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
@@ -113,6 +138,7 @@ class SubscriptionService:
|
||||
return self._config_error
|
||||
|
||||
def _ensure_configured(self) -> None:
|
||||
self._refresh_configuration()
|
||||
if not self.api or not self.is_configured:
|
||||
raise RemnaWaveAPIError(
|
||||
self._config_error or "RemnaWave API не настроен"
|
||||
|
||||
@@ -56,9 +56,16 @@ class ChoiceOption:
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ReadOnlySettingError(RuntimeError):
|
||||
"""Исключение, выбрасываемое при попытке изменить настройку только для чтения."""
|
||||
|
||||
|
||||
class BotConfigurationService:
|
||||
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
|
||||
|
||||
READ_ONLY_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"}
|
||||
PLAIN_TEXT_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"}
|
||||
|
||||
CATEGORY_TITLES: Dict[str, str] = {
|
||||
"CORE": "🤖 Основные настройки",
|
||||
"SUPPORT": "💬 Поддержка и тикеты",
|
||||
@@ -71,6 +78,7 @@ class BotConfigurationService:
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 MulenPay",
|
||||
"PAL24": "🏦 PAL24 / PayPalych",
|
||||
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
|
||||
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
|
||||
"PERIODS": "📆 Периоды подписок",
|
||||
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
|
||||
@@ -118,6 +126,7 @@ class BotConfigurationService:
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
"TELEGRAM": "Telegram Stars и их стоимость.",
|
||||
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
|
||||
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
|
||||
"PERIODS": "Доступные периоды подписок и продлений.",
|
||||
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
|
||||
@@ -184,6 +193,7 @@ class BotConfigurationService:
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
|
||||
"REFERRED_USER_REWARD": "REFERRAL",
|
||||
"DEFAULT_AUTOPAY_ENABLED": "AUTOPAY",
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
"TRIAL_WARNING_HOURS": "TRIAL",
|
||||
@@ -255,6 +265,7 @@ class BotConfigurationService:
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
|
||||
"CONNECT_BUTTON_HAPP": "HAPP",
|
||||
"HAPP_": "HAPP",
|
||||
"SKIP_": "SKIP",
|
||||
@@ -394,6 +405,20 @@ class BotConfigurationService:
|
||||
"warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.",
|
||||
"dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD",
|
||||
},
|
||||
"EXTERNAL_ADMIN_TOKEN": {
|
||||
"description": "Приватный токен, который использует внешняя админка для проверки запросов.",
|
||||
"format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.",
|
||||
"example": "Генерируется автоматически",
|
||||
"warning": "Токен обновится при смене username или токена бота.",
|
||||
"dependencies": "Username телеграм-бота, токен бота",
|
||||
},
|
||||
"EXTERNAL_ADMIN_TOKEN_BOT_ID": {
|
||||
"description": "Идентификатор телеграм-бота, с которым связан токен внешней админки.",
|
||||
"format": "Проставляется автоматически после первого запуска и не редактируется вручную.",
|
||||
"example": "123456789",
|
||||
"warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.",
|
||||
"dependencies": "Результат вызова getMe() в Telegram Bot API",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -405,6 +430,10 @@ class BotConfigurationService:
|
||||
definition = cls.get_definition(key)
|
||||
return definition.python_type is bool
|
||||
|
||||
@classmethod
|
||||
def is_read_only(cls, key: str) -> bool:
|
||||
return key in cls.READ_ONLY_KEYS
|
||||
|
||||
@classmethod
|
||||
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
|
||||
if isinstance(value, bool):
|
||||
@@ -455,6 +484,8 @@ class BotConfigurationService:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
return "—"
|
||||
if key in cls.PLAIN_TEXT_KEYS:
|
||||
return cleaned
|
||||
if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")):
|
||||
return "••••••••"
|
||||
items = cls._split_comma_values(cleaned)
|
||||
@@ -860,7 +891,17 @@ class BotConfigurationService:
|
||||
return parsed_value
|
||||
|
||||
@classmethod
|
||||
async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None:
|
||||
async def set_value(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
value: Any,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if cls.is_read_only(key) and not force:
|
||||
raise ReadOnlySettingError(f"Setting {key} is read-only")
|
||||
|
||||
raw_value = cls.serialize_value(key, value)
|
||||
await upsert_system_setting(db, key, raw_value)
|
||||
cls._overrides_raw[key] = raw_value
|
||||
@@ -870,7 +911,16 @@ class BotConfigurationService:
|
||||
await cls._sync_default_web_api_token()
|
||||
|
||||
@classmethod
|
||||
async def reset_value(cls, db: AsyncSession, key: str) -> None:
|
||||
async def reset_value(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if cls.is_read_only(key) and not force:
|
||||
raise ReadOnlySettingError(f"Setting {key} is read-only")
|
||||
|
||||
await delete_system_setting(db, key)
|
||||
cls._overrides_raw.pop(key, None)
|
||||
original = cls.get_original_value(key)
|
||||
@@ -925,6 +975,7 @@ class BotConfigurationService:
|
||||
"category_key": definition.category_key,
|
||||
"category_label": definition.category_label,
|
||||
"has_override": has_override,
|
||||
"is_read_only": cls.is_read_only(key),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ from app.database.crud.user import (
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import (
|
||||
get_subscription_by_user_id,
|
||||
decrement_subscription_server_counts,
|
||||
)
|
||||
from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
@@ -534,7 +537,13 @@ class UserService:
|
||||
)
|
||||
)
|
||||
subscription_servers = subscription_servers_result.scalars().all()
|
||||
|
||||
|
||||
await decrement_subscription_server_counts(
|
||||
db,
|
||||
user.subscription,
|
||||
subscription_servers=subscription_servers,
|
||||
)
|
||||
|
||||
if subscription_servers:
|
||||
logger.info(f"🔄 Удаляем {len(subscription_servers)} связей подписка-сервер")
|
||||
await db.execute(
|
||||
|
||||
+18
-2
@@ -87,6 +87,12 @@ class AdminStates(StatesGroup):
|
||||
editing_user_traffic = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
editing_privacy_policy = State()
|
||||
editing_public_offer = State()
|
||||
creating_faq_title = State()
|
||||
creating_faq_content = State()
|
||||
editing_faq_title = State()
|
||||
editing_faq_content = State()
|
||||
editing_notification_value = State()
|
||||
|
||||
confirming_sync = State()
|
||||
@@ -97,14 +103,24 @@ class AdminStates(StatesGroup):
|
||||
editing_server_limit = State()
|
||||
editing_server_description = State()
|
||||
editing_server_promo_groups = State()
|
||||
|
||||
|
||||
creating_server_uuid = State()
|
||||
creating_server_name = State()
|
||||
creating_server_price = State()
|
||||
creating_server_country = State()
|
||||
|
||||
|
||||
editing_welcome_text = State()
|
||||
waiting_for_message_buttons = "waiting_for_message_buttons"
|
||||
|
||||
editing_promo_offer_message = State()
|
||||
editing_promo_offer_button = State()
|
||||
editing_promo_offer_valid_hours = State()
|
||||
editing_promo_offer_active_duration = State()
|
||||
editing_promo_offer_discount = State()
|
||||
editing_promo_offer_test_duration = State()
|
||||
editing_promo_offer_squads = State()
|
||||
selecting_promo_offer_user = State()
|
||||
searching_promo_offer_user = State()
|
||||
|
||||
# Состояния для отслеживания источника перехода
|
||||
viewing_user_from_balance_list = State()
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from pathlib import Path
|
||||
from aiogram.types import Message, FSInputFile, InputMediaPhoto
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import FSInputFile, InputMediaPhoto, Message
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
LOGO_PATH = Path(settings.LOGO_FILE)
|
||||
_PRIVACY_RESTRICTED_CODE = "BUTTON_USER_PRIVACY_RESTRICTED"
|
||||
|
||||
|
||||
def is_qr_message(message: Message) -> bool:
|
||||
@@ -15,6 +19,65 @@ _original_answer = Message.answer
|
||||
_original_edit_text = Message.edit_text
|
||||
|
||||
|
||||
def _get_language(message: Message) -> str | None:
|
||||
try:
|
||||
user = message.from_user
|
||||
if user and getattr(user, "language_code", None):
|
||||
return user.language_code
|
||||
except AttributeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _default_privacy_hint(language: str | None) -> str:
|
||||
if language and language.lower().startswith("en"):
|
||||
return (
|
||||
"⚠️ Telegram blocked the contact request button because of your privacy settings. "
|
||||
"Please allow sharing your contact information or send the required details manually."
|
||||
)
|
||||
return (
|
||||
"⚠️ Telegram запретил кнопку запроса контакта из-за настроек приватности. "
|
||||
"Разрешите отправку контакта в настройках Telegram или отправьте данные вручную."
|
||||
)
|
||||
|
||||
|
||||
def append_privacy_hint(text: str | None, language: str | None) -> str:
|
||||
base_text = text or ""
|
||||
try:
|
||||
hint = get_texts(language).t(
|
||||
"PRIVACY_RESTRICTED_BUTTON_HINT",
|
||||
default=_default_privacy_hint(language),
|
||||
)
|
||||
except Exception:
|
||||
hint = _default_privacy_hint(language)
|
||||
|
||||
hint = hint.strip()
|
||||
if not hint:
|
||||
return base_text
|
||||
|
||||
if hint in base_text:
|
||||
return base_text
|
||||
|
||||
if base_text:
|
||||
return f"{base_text}\n\n{hint}"
|
||||
return hint
|
||||
|
||||
|
||||
def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
||||
safe_kwargs: Dict[str, Any] = dict(kwargs or {})
|
||||
safe_kwargs.pop("reply_markup", None)
|
||||
return safe_kwargs
|
||||
|
||||
|
||||
def is_privacy_restricted_error(error: Exception) -> bool:
|
||||
if not isinstance(error, TelegramBadRequest):
|
||||
return False
|
||||
|
||||
message = getattr(error, "message", "") or ""
|
||||
description = str(error)
|
||||
return _PRIVACY_RESTRICTED_CODE in message or _PRIVACY_RESTRICTED_CODE in description
|
||||
|
||||
|
||||
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
@@ -25,12 +88,20 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
language = _get_language(self)
|
||||
|
||||
if LOGO_PATH.exists():
|
||||
try:
|
||||
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_privacy_restricted_error(error):
|
||||
fallback_text = append_privacy_hint(text, language)
|
||||
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
|
||||
return await _original_answer(self, fallback_text, **safe_kwargs)
|
||||
# Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
# Фоллбек, если Telegram ругается на caption: отправим как текст
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
|
||||
@@ -40,6 +111,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
if self.photo:
|
||||
language = _get_language(self)
|
||||
# Если caption потенциально слишком длинный — отправим как текст вместо caption
|
||||
try:
|
||||
if text is not None and len(text) > 900:
|
||||
@@ -59,14 +131,23 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
else:
|
||||
media = self.photo[-1].file_id
|
||||
media_kwargs = {"media": media, "caption": text}
|
||||
if "parse_mode" in kwargs:
|
||||
_pm = kwargs.pop("parse_mode")
|
||||
edit_kwargs = dict(kwargs)
|
||||
if "parse_mode" in edit_kwargs:
|
||||
_pm = edit_kwargs.pop("parse_mode")
|
||||
media_kwargs["parse_mode"] = _pm if _pm is not None else "HTML"
|
||||
else:
|
||||
media_kwargs["parse_mode"] = "HTML"
|
||||
try:
|
||||
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
|
||||
except TelegramBadRequest:
|
||||
return await self.edit_media(InputMediaPhoto(**media_kwargs), **edit_kwargs)
|
||||
except TelegramBadRequest as error:
|
||||
if is_privacy_restricted_error(error):
|
||||
fallback_text = append_privacy_hint(text, language)
|
||||
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
|
||||
try:
|
||||
await self.delete()
|
||||
except Exception:
|
||||
pass
|
||||
return await _original_answer(self, fallback_text, **safe_kwargs)
|
||||
# Фоллбек: удалим и отправим обычный текст без фото
|
||||
try:
|
||||
await self.delete()
|
||||
|
||||
+68
-27
@@ -3,7 +3,13 @@ from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import FSInputFile, InputMediaPhoto
|
||||
|
||||
from app.config import settings
|
||||
from .message_patch import LOGO_PATH, is_qr_message
|
||||
from .message_patch import (
|
||||
LOGO_PATH,
|
||||
append_privacy_hint,
|
||||
is_privacy_restricted_error,
|
||||
is_qr_message,
|
||||
prepare_privacy_safe_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_media(message: types.Message):
|
||||
@@ -17,35 +23,69 @@ def _resolve_media(message: types.Message):
|
||||
return FSInputFile(LOGO_PATH)
|
||||
|
||||
|
||||
def _get_language(callback: types.CallbackQuery) -> str | None:
|
||||
try:
|
||||
user = callback.from_user
|
||||
if user and getattr(user, "language_code", None):
|
||||
return user.language_code
|
||||
except AttributeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None):
|
||||
kwargs: dict[str, object] = {}
|
||||
if parse_mode is not None:
|
||||
kwargs["parse_mode"] = parse_mode
|
||||
if keyboard is not None:
|
||||
kwargs["reply_markup"] = keyboard
|
||||
return kwargs
|
||||
|
||||
|
||||
async def _answer_text(
|
||||
callback: types.CallbackQuery,
|
||||
caption: str,
|
||||
keyboard: types.InlineKeyboardMarkup | None,
|
||||
parse_mode: str | None,
|
||||
error: TelegramBadRequest | None = None,
|
||||
) -> None:
|
||||
language = _get_language(callback)
|
||||
kwargs = _build_base_kwargs(keyboard, parse_mode)
|
||||
|
||||
if error and is_privacy_restricted_error(error):
|
||||
caption = append_privacy_hint(caption, language)
|
||||
kwargs = prepare_privacy_safe_kwargs(kwargs)
|
||||
|
||||
kwargs.setdefault("parse_mode", parse_mode or "HTML")
|
||||
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def edit_or_answer_photo(
|
||||
callback: types.CallbackQuery,
|
||||
caption: str,
|
||||
keyboard: types.InlineKeyboardMarkup,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> None:
|
||||
resolved_parse_mode = parse_mode or "HTML"
|
||||
# Если режим логотипа выключен — работаем текстом
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
except TelegramBadRequest as error:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
|
||||
return
|
||||
|
||||
# Если текст слишком длинный для caption — отправим как текст
|
||||
@@ -53,13 +93,9 @@ async def edit_or_answer_photo(
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
|
||||
except TelegramBadRequest as error:
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
|
||||
return
|
||||
|
||||
media = _resolve_media(callback.message)
|
||||
@@ -68,7 +104,14 @@ async def edit_or_answer_photo(
|
||||
InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or "HTML")),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
except TelegramBadRequest as error:
|
||||
if is_privacy_restricted_error(error):
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
|
||||
return
|
||||
# Фоллбек: если не удалось обновить фото — отправим текст, чтобы не упасть на лимите caption
|
||||
try:
|
||||
await callback.message.delete()
|
||||
@@ -80,12 +123,10 @@ async def edit_or_answer_photo(
|
||||
photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=(parse_mode or "HTML"),
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
except TelegramBadRequest as photo_error:
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, photo_error)
|
||||
except Exception:
|
||||
# Последний фоллбек — обычный текст
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=(parse_mode or "HTML"),
|
||||
)
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
|
||||
from app.database.models import ServerSquad, SubscriptionTemporaryAccess, User
|
||||
|
||||
|
||||
def _escape_format_braces(text: str) -> str:
|
||||
"""Escape braces so str.format treats them as literals."""
|
||||
|
||||
return text.replace("{", "{{").replace("}", "}}")
|
||||
|
||||
|
||||
def get_user_active_promo_discount_percent(user: Optional[User]) -> int:
|
||||
if not user:
|
||||
return 0
|
||||
|
||||
try:
|
||||
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||||
if expires_at and expires_at <= datetime.utcnow():
|
||||
return 0
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
def _format_time_left(seconds_left: int, language: str) -> str:
|
||||
total_minutes = max(1, math.ceil(seconds_left / 60))
|
||||
days, remainder_minutes = divmod(total_minutes, 60 * 24)
|
||||
hours, minutes = divmod(remainder_minutes, 60)
|
||||
|
||||
language_code = (language or "ru").split("-")[0].lower()
|
||||
if language_code == "en":
|
||||
day_label, hour_label, minute_label = "d", "h", "m"
|
||||
else:
|
||||
day_label, hour_label, minute_label = "д", "ч", "м"
|
||||
|
||||
parts: list[str] = []
|
||||
if days:
|
||||
parts.append(f"{days}{day_label}")
|
||||
if hours or days:
|
||||
parts.append(f"{hours}{hour_label}")
|
||||
parts.append(f"{minutes}{minute_label}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _build_progress_bar(seconds_left: int, total_seconds: int) -> str:
|
||||
if total_seconds <= 0:
|
||||
total_seconds = seconds_left or 1
|
||||
|
||||
ratio = max(0.0, min(1.0, seconds_left / total_seconds))
|
||||
bar_length = 10
|
||||
filled_segments = int(round(ratio * bar_length))
|
||||
filled_segments = max(0, min(bar_length, filled_segments))
|
||||
if filled_segments == 0 and seconds_left > 0:
|
||||
filled_segments = 1
|
||||
|
||||
return f"[{'█' * filled_segments}{'░' * (bar_length - filled_segments)}]"
|
||||
|
||||
|
||||
async def build_promo_offer_timer_line(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
texts,
|
||||
) -> Optional[str]:
|
||||
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||||
if not expires_at:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
if expires_at <= now:
|
||||
return None
|
||||
|
||||
seconds_left = int((expires_at - now).total_seconds())
|
||||
if seconds_left <= 0:
|
||||
return None
|
||||
|
||||
total_seconds: Optional[int] = None
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception:
|
||||
offer = None
|
||||
|
||||
if offer and getattr(offer, "claimed_at", None):
|
||||
total_seconds = int((expires_at - offer.claimed_at).total_seconds())
|
||||
if total_seconds <= 0:
|
||||
total_seconds = None
|
||||
|
||||
if total_seconds is None and offer:
|
||||
extra_data = getattr(offer, "extra_data", None)
|
||||
if isinstance(extra_data, dict):
|
||||
raw_duration = (
|
||||
extra_data.get("active_discount_hours")
|
||||
or extra_data.get("duration_hours")
|
||||
)
|
||||
else:
|
||||
raw_duration = None
|
||||
try:
|
||||
if raw_duration:
|
||||
total_seconds = int(float(raw_duration) * 3600)
|
||||
except (TypeError, ValueError):
|
||||
total_seconds = None
|
||||
|
||||
if total_seconds is None or total_seconds <= 0:
|
||||
total_seconds = seconds_left
|
||||
|
||||
bar = _build_progress_bar(seconds_left, total_seconds)
|
||||
time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru"))
|
||||
|
||||
template = texts.t(
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER",
|
||||
"⏳ Discount active for {time_left}\n<code>{bar}</code>",
|
||||
)
|
||||
return template.format(bar=bar, time_left=time_left_text)
|
||||
|
||||
|
||||
async def build_promo_offer_hint(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
texts,
|
||||
percent: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
if percent is None:
|
||||
percent = get_user_active_promo_discount_percent(user)
|
||||
|
||||
if percent <= 0:
|
||||
return None
|
||||
|
||||
base_hint = texts.t(
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT",
|
||||
"⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
|
||||
).format(percent=percent)
|
||||
|
||||
timer_line = await build_promo_offer_timer_line(db, user, texts)
|
||||
if timer_line:
|
||||
return f"{base_hint}\n{timer_line}"
|
||||
|
||||
return base_hint
|
||||
|
||||
|
||||
async def build_test_access_hint(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
texts,
|
||||
) -> Optional[str]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
subscription_id = getattr(subscription, "id", None)
|
||||
if not subscription_id:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.options(selectinload(SubscriptionTemporaryAccess.offer))
|
||||
.where(
|
||||
SubscriptionTemporaryAccess.subscription_id == subscription_id,
|
||||
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
|
||||
SubscriptionTemporaryAccess.expires_at > now,
|
||||
)
|
||||
.order_by(SubscriptionTemporaryAccess.expires_at.desc())
|
||||
)
|
||||
entries: Sequence[SubscriptionTemporaryAccess] = result.scalars().all()
|
||||
|
||||
active_entries = [
|
||||
entry for entry in entries if entry.expires_at and entry.expires_at > now
|
||||
]
|
||||
if not active_entries:
|
||||
return None
|
||||
|
||||
latest_expiry = max(entry.expires_at for entry in active_entries)
|
||||
seconds_left = int((latest_expiry - now).total_seconds())
|
||||
if seconds_left <= 0:
|
||||
return None
|
||||
|
||||
total_seconds: Optional[int] = None
|
||||
for entry in active_entries:
|
||||
offer = entry.offer
|
||||
claimed_at = getattr(offer, "claimed_at", None) if offer else None
|
||||
if claimed_at:
|
||||
total = int((entry.expires_at - claimed_at).total_seconds())
|
||||
if total > 0 and (total_seconds is None or total > total_seconds):
|
||||
total_seconds = total
|
||||
|
||||
if total_seconds is None or total_seconds <= 0:
|
||||
total_seconds = seconds_left
|
||||
|
||||
bar = _build_progress_bar(seconds_left, total_seconds)
|
||||
time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru"))
|
||||
|
||||
unique_squad_uuids: list[str] = []
|
||||
seen_squads: set[str] = set()
|
||||
for entry in active_entries:
|
||||
squad_uuid = getattr(entry, "squad_uuid", None)
|
||||
if squad_uuid and squad_uuid not in seen_squads:
|
||||
seen_squads.add(squad_uuid)
|
||||
unique_squad_uuids.append(squad_uuid)
|
||||
|
||||
squad_display_names: list[str] = []
|
||||
if unique_squad_uuids:
|
||||
squads_result = await db.execute(
|
||||
select(ServerSquad.squad_uuid, ServerSquad.display_name).where(
|
||||
ServerSquad.squad_uuid.in_(unique_squad_uuids)
|
||||
)
|
||||
)
|
||||
names_map = {
|
||||
squad_uuid: html.escape(display_name)
|
||||
for squad_uuid, display_name in squads_result.all()
|
||||
if display_name
|
||||
}
|
||||
for squad_uuid in unique_squad_uuids:
|
||||
if squad_uuid in names_map:
|
||||
squad_display_names.append(names_map[squad_uuid])
|
||||
else:
|
||||
squad_display_names.append(html.escape(squad_uuid))
|
||||
|
||||
if squad_display_names:
|
||||
servers_display = ", ".join(squad_display_names)
|
||||
elif unique_squad_uuids:
|
||||
servers_display = ", ".join(
|
||||
html.escape(squad_uuid) for squad_uuid in unique_squad_uuids
|
||||
)
|
||||
else:
|
||||
servers_display = str(len(active_entries))
|
||||
|
||||
header_template = texts.t(
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER",
|
||||
"🧪 Test servers active: {servers}",
|
||||
)
|
||||
timer_template = texts.t(
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER",
|
||||
"⏳ Access active for {time_left}\n<code>{bar}</code>",
|
||||
)
|
||||
|
||||
header = header_template.format(
|
||||
servers=_escape_format_braces(servers_display)
|
||||
)
|
||||
timer_line = timer_template.format(time_left=time_left_text, bar=bar)
|
||||
|
||||
return f"{header}\n{timer_line}"
|
||||
@@ -48,18 +48,36 @@ async def update_or_create_subscription(
|
||||
for key, value in subscription_data.items():
|
||||
if hasattr(existing_subscription, key):
|
||||
setattr(existing_subscription, key, value)
|
||||
|
||||
|
||||
existing_subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
|
||||
logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}")
|
||||
return existing_subscription
|
||||
|
||||
|
||||
else:
|
||||
subscription_defaults = dict(subscription_data)
|
||||
autopay_enabled = subscription_defaults.pop(
|
||||
"autopay_enabled", None
|
||||
)
|
||||
autopay_days_before = subscription_defaults.pop(
|
||||
"autopay_days_before", None
|
||||
)
|
||||
|
||||
new_subscription = Subscription(
|
||||
user_id=user_id,
|
||||
**subscription_data
|
||||
autopay_enabled=(
|
||||
settings.is_autopay_enabled_by_default()
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
),
|
||||
autopay_days_before=(
|
||||
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
),
|
||||
**subscription_defaults
|
||||
)
|
||||
|
||||
db.add(new_subscription)
|
||||
|
||||
@@ -12,9 +12,12 @@ from .routes import (
|
||||
campaigns,
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
promocodes,
|
||||
miniapp,
|
||||
promo_groups,
|
||||
promo_offers,
|
||||
pages,
|
||||
remnawave,
|
||||
stats,
|
||||
subscriptions,
|
||||
@@ -38,6 +41,10 @@ OPENAPI_TAGS = [
|
||||
"name": "settings",
|
||||
"description": "Получение и изменение конфигурации бота из административной панели.",
|
||||
},
|
||||
{
|
||||
"name": "main-menu",
|
||||
"description": "Управление кнопками главного меню Telegram-бота.",
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
"description": "Управление пользователями, балансом и статусами подписок.",
|
||||
@@ -58,6 +65,10 @@ OPENAPI_TAGS = [
|
||||
"name": "promo-groups",
|
||||
"description": "Создание и управление промо-группами и их участниками.",
|
||||
},
|
||||
{
|
||||
"name": "promo-offers",
|
||||
"description": "Управление промо-предложениями, шаблонами и журналом событий.",
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": "Управление токенами доступа к административному API.",
|
||||
@@ -73,6 +84,10 @@ OPENAPI_TAGS = [
|
||||
"name": "miniapp",
|
||||
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
||||
},
|
||||
{
|
||||
"name": "pages",
|
||||
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -109,6 +124,13 @@ def create_web_api_app() -> FastAPI:
|
||||
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(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
|
||||
app.include_router(
|
||||
main_menu_buttons.router,
|
||||
prefix="/main-menu/buttons",
|
||||
tags=["main-menu"],
|
||||
)
|
||||
app.include_router(pages.router, prefix="/pages", tags=["pages"])
|
||||
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
|
||||
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
|
||||
app.include_router(backups.router, prefix="/backups", tags=["backups"])
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from . import (
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
miniapp,
|
||||
promo_offers,
|
||||
pages,
|
||||
promo_groups,
|
||||
remnawave,
|
||||
stats,
|
||||
@@ -15,7 +18,10 @@ from . import (
|
||||
__all__ = [
|
||||
"config",
|
||||
"health",
|
||||
"main_menu_buttons",
|
||||
"miniapp",
|
||||
"promo_offers",
|
||||
"pages",
|
||||
"promo_groups",
|
||||
"remnawave",
|
||||
"stats",
|
||||
|
||||
@@ -5,7 +5,10 @@ from typing import Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.config import (
|
||||
@@ -94,6 +97,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
|
||||
current=current,
|
||||
original=original,
|
||||
has_override=has_override,
|
||||
read_only=bot_configuration_service.is_read_only(definition.key),
|
||||
choices=choices,
|
||||
)
|
||||
|
||||
@@ -153,7 +157,10 @@ async def update_setting(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
value = _coerce_value(key, payload.value)
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError as error:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
return _serialize_definition(definition)
|
||||
@@ -170,6 +177,9 @@ async def reset_setting(
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
try:
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
except ReadOnlySettingError as error:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
return _serialize_definition(definition)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.main_menu_button import (
|
||||
count_main_menu_buttons,
|
||||
create_main_menu_button,
|
||||
delete_main_menu_button,
|
||||
get_main_menu_button_by_id,
|
||||
get_main_menu_buttons,
|
||||
update_main_menu_button,
|
||||
)
|
||||
from app.database.models import MainMenuButton
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.main_menu_buttons import (
|
||||
MainMenuButtonCreateRequest,
|
||||
MainMenuButtonListResponse,
|
||||
MainMenuButtonResponse,
|
||||
MainMenuButtonUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize(button: MainMenuButton) -> MainMenuButtonResponse:
|
||||
return MainMenuButtonResponse(
|
||||
id=button.id,
|
||||
text=button.text,
|
||||
action_type=button.action_type_enum,
|
||||
action_value=button.action_value,
|
||||
visibility=button.visibility_enum,
|
||||
is_active=button.is_active,
|
||||
display_order=button.display_order,
|
||||
created_at=button.created_at,
|
||||
updated_at=button.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=MainMenuButtonListResponse)
|
||||
async def list_main_menu_buttons(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> MainMenuButtonListResponse:
|
||||
total = await count_main_menu_buttons(db)
|
||||
buttons = await get_main_menu_buttons(db, limit=limit, offset=offset)
|
||||
|
||||
return MainMenuButtonListResponse(
|
||||
items=[_serialize(button) for button in buttons],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=MainMenuButtonResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_main_menu_button_endpoint(
|
||||
payload: MainMenuButtonCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MainMenuButtonResponse:
|
||||
button = await create_main_menu_button(
|
||||
db,
|
||||
text=payload.text,
|
||||
action_type=payload.action_type,
|
||||
action_value=payload.action_value,
|
||||
visibility=payload.visibility,
|
||||
is_active=payload.is_active,
|
||||
display_order=payload.display_order,
|
||||
)
|
||||
|
||||
MainMenuButtonService.invalidate_cache()
|
||||
return _serialize(button)
|
||||
|
||||
|
||||
@router.patch("/{button_id}", response_model=MainMenuButtonResponse)
|
||||
async def update_main_menu_button_endpoint(
|
||||
button_id: int,
|
||||
payload: MainMenuButtonUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MainMenuButtonResponse:
|
||||
button = await get_main_menu_button_by_id(db, button_id)
|
||||
if not button:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found")
|
||||
|
||||
update_payload = payload.dict(exclude_unset=True)
|
||||
button = await update_main_menu_button(db, button, **update_payload)
|
||||
|
||||
MainMenuButtonService.invalidate_cache()
|
||||
return _serialize(button)
|
||||
|
||||
|
||||
@router.delete("/{button_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_main_menu_button_endpoint(
|
||||
button_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
button = await get_main_menu_button_by_id(db, button_id)
|
||||
if not button:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found")
|
||||
|
||||
await delete_main_menu_button(db, button)
|
||||
MainMenuButtonService.invalidate_cache()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
+1193
-4
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,492 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.faq import get_faq_page_by_id
|
||||
from app.database.crud.rules import (
|
||||
clear_all_rules,
|
||||
create_or_update_rules,
|
||||
get_all_rules_versions,
|
||||
get_rules_by_language,
|
||||
restore_rules_version,
|
||||
)
|
||||
from app.database.models import ServiceRule
|
||||
from app.services.faq_service import FaqService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.pages import (
|
||||
FaqPageCreateRequest,
|
||||
FaqPageListResponse,
|
||||
FaqPageResponse,
|
||||
FaqPageUpdateRequest,
|
||||
FaqReorderRequest,
|
||||
FaqStatusResponse,
|
||||
FaqStatusUpdateRequest,
|
||||
RichTextPageResponse,
|
||||
RichTextPageUpdateRequest,
|
||||
ServiceRulesHistoryResponse,
|
||||
ServiceRulesResponse,
|
||||
ServiceRulesUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_rich_page(
|
||||
*,
|
||||
requested_language: str,
|
||||
content: str,
|
||||
language: str,
|
||||
is_enabled: Optional[bool],
|
||||
created_at,
|
||||
updated_at,
|
||||
splitter,
|
||||
) -> RichTextPageResponse:
|
||||
pages = splitter(content or "")
|
||||
return RichTextPageResponse(
|
||||
requested_language=requested_language,
|
||||
language=language,
|
||||
is_enabled=is_enabled,
|
||||
content=content or "",
|
||||
content_pages=pages,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_faq_page(page) -> FaqPageResponse:
|
||||
return FaqPageResponse(
|
||||
id=page.id,
|
||||
language=page.language,
|
||||
title=page.title,
|
||||
content=page.content,
|
||||
content_pages=FaqService.split_content_into_pages(page.content),
|
||||
display_order=page.display_order,
|
||||
is_active=page.is_active,
|
||||
created_at=page.created_at,
|
||||
updated_at=page.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_rules(rule: ServiceRule) -> ServiceRulesResponse:
|
||||
return ServiceRulesResponse(
|
||||
id=rule.id,
|
||||
title=rule.title,
|
||||
content=rule.content,
|
||||
language=rule.language,
|
||||
is_active=rule.is_active,
|
||||
created_at=rule.created_at,
|
||||
updated_at=rule.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public-offer", response_model=RichTextPageResponse)
|
||||
async def get_public_offer(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True, description="Использовать запасной язык, если контента нет"),
|
||||
include_disabled: bool = Query(
|
||||
True,
|
||||
description="Возвращать контент даже если страница выключена",
|
||||
),
|
||||
) -> RichTextPageResponse:
|
||||
requested_lang = PublicOfferService.normalize_language(language)
|
||||
offer = await PublicOfferService.get_offer(db, requested_lang, fallback=fallback)
|
||||
|
||||
if not offer:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer not found")
|
||||
|
||||
if not include_disabled and not offer.is_enabled:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer disabled")
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=requested_lang,
|
||||
language=offer.language,
|
||||
is_enabled=offer.is_enabled,
|
||||
content=offer.content or "",
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
splitter=PublicOfferService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/public-offer", response_model=RichTextPageResponse)
|
||||
async def update_public_offer(
|
||||
payload: RichTextPageUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RichTextPageResponse:
|
||||
lang = PublicOfferService.normalize_language(payload.language)
|
||||
offer = await PublicOfferService.save_offer(db, lang, payload.content)
|
||||
|
||||
if payload.is_enabled is not None:
|
||||
offer = await PublicOfferService.set_enabled(db, lang, payload.is_enabled)
|
||||
|
||||
refreshed = await PublicOfferService.get_offer(db, lang, fallback=False)
|
||||
offer = refreshed or offer
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=lang,
|
||||
language=offer.language,
|
||||
is_enabled=offer.is_enabled,
|
||||
content=offer.content or "",
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
splitter=PublicOfferService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/privacy-policy", response_model=RichTextPageResponse)
|
||||
async def get_privacy_policy(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True),
|
||||
include_disabled: bool = Query(True),
|
||||
) -> RichTextPageResponse:
|
||||
requested_lang = PrivacyPolicyService.normalize_language(language)
|
||||
policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=fallback)
|
||||
|
||||
if not policy:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy not found")
|
||||
|
||||
if not include_disabled and not policy.is_enabled:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy disabled")
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=requested_lang,
|
||||
language=policy.language,
|
||||
is_enabled=policy.is_enabled,
|
||||
content=policy.content or "",
|
||||
created_at=policy.created_at,
|
||||
updated_at=policy.updated_at,
|
||||
splitter=PrivacyPolicyService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/privacy-policy", response_model=RichTextPageResponse)
|
||||
async def update_privacy_policy(
|
||||
payload: RichTextPageUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> RichTextPageResponse:
|
||||
lang = PrivacyPolicyService.normalize_language(payload.language)
|
||||
policy = await PrivacyPolicyService.save_policy(db, lang, payload.content)
|
||||
|
||||
if payload.is_enabled is not None:
|
||||
policy = await PrivacyPolicyService.set_enabled(db, lang, payload.is_enabled)
|
||||
|
||||
refreshed = await PrivacyPolicyService.get_policy(db, lang, fallback=False)
|
||||
policy = refreshed or policy
|
||||
|
||||
return _serialize_rich_page(
|
||||
requested_language=lang,
|
||||
language=policy.language,
|
||||
is_enabled=policy.is_enabled,
|
||||
content=policy.content or "",
|
||||
created_at=policy.created_at,
|
||||
updated_at=policy.updated_at,
|
||||
splitter=PrivacyPolicyService.split_content_into_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/faq", response_model=FaqPageListResponse)
|
||||
async def list_faq_pages(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
include_inactive: bool = Query(True),
|
||||
fallback: bool = Query(True),
|
||||
) -> FaqPageListResponse:
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
pages = await FaqService.get_pages(
|
||||
db,
|
||||
requested_lang,
|
||||
include_inactive=include_inactive,
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
resolved_language = requested_lang
|
||||
if pages:
|
||||
resolved_language = pages[0].language
|
||||
|
||||
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
|
||||
is_enabled = bool(setting.is_enabled) if setting else False
|
||||
if setting:
|
||||
resolved_language = setting.language
|
||||
|
||||
serialized = [_serialize_faq_page(page) for page in pages]
|
||||
return FaqPageListResponse(
|
||||
requested_language=requested_lang,
|
||||
language=resolved_language,
|
||||
is_enabled=is_enabled,
|
||||
total=len(serialized),
|
||||
items=serialized,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/faq", response_model=FaqPageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_faq_page(
|
||||
payload: FaqPageCreateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqPageResponse:
|
||||
lang = FaqService.normalize_language(payload.language)
|
||||
is_active = True if payload.is_active is None else payload.is_active
|
||||
|
||||
page = await FaqService.create_page(
|
||||
db,
|
||||
language=lang,
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
display_order=payload.display_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
return _serialize_faq_page(page)
|
||||
|
||||
|
||||
@router.get("/faq/{page_id}", response_model=FaqPageResponse)
|
||||
async def get_faq_page(
|
||||
page_id: int,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
include_inactive: bool = Query(True),
|
||||
) -> FaqPageResponse:
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
page = await FaqService.get_page(
|
||||
db,
|
||||
page_id,
|
||||
requested_lang,
|
||||
include_inactive=include_inactive,
|
||||
fallback=True,
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
|
||||
|
||||
return _serialize_faq_page(page)
|
||||
|
||||
|
||||
@router.put("/faq/{page_id}", response_model=FaqPageResponse)
|
||||
async def update_faq_page(
|
||||
page_id: int,
|
||||
payload: FaqPageUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqPageResponse:
|
||||
page = await get_faq_page_by_id(db, page_id)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
|
||||
|
||||
updated = await FaqService.update_page(
|
||||
db,
|
||||
page,
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
display_order=payload.display_order,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
|
||||
return _serialize_faq_page(updated)
|
||||
|
||||
|
||||
@router.delete("/faq/{page_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_faq_page(
|
||||
page_id: int,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
page = await get_faq_page_by_id(db, page_id)
|
||||
if not page:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found")
|
||||
|
||||
await FaqService.delete_page(db, page_id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/faq/reorder", response_model=FaqPageListResponse)
|
||||
async def reorder_faq_pages(
|
||||
payload: FaqReorderRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqPageListResponse:
|
||||
lang = FaqService.normalize_language(payload.language)
|
||||
|
||||
ordered_payload = sorted(payload.items, key=lambda item: item.display_order)
|
||||
|
||||
existing_pages = await FaqService.get_pages(
|
||||
db,
|
||||
lang,
|
||||
include_inactive=True,
|
||||
fallback=False,
|
||||
)
|
||||
pages_by_id = {page.id: page for page in existing_pages}
|
||||
|
||||
pages: List[Any] = []
|
||||
for item in ordered_payload:
|
||||
page = pages_by_id.get(item.id)
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
f"FAQ page {item.id} not found for language {lang}",
|
||||
)
|
||||
pages.append(page)
|
||||
|
||||
ordered_ids = {item.id for item in ordered_payload}
|
||||
remaining = [page for page in existing_pages if page.id not in ordered_ids]
|
||||
pages.extend(sorted(remaining, key=lambda page: (page.display_order, page.id)))
|
||||
|
||||
await FaqService.reorder_pages(db, lang, pages)
|
||||
|
||||
updated_pages = await FaqService.get_pages(
|
||||
db,
|
||||
lang,
|
||||
include_inactive=True,
|
||||
fallback=False,
|
||||
)
|
||||
setting = await FaqService.get_setting(db, lang, fallback=False)
|
||||
serialized = [_serialize_faq_page(page) for page in updated_pages]
|
||||
return FaqPageListResponse(
|
||||
requested_language=lang,
|
||||
language=lang,
|
||||
is_enabled=bool(setting.is_enabled) if setting else False,
|
||||
total=len(serialized),
|
||||
items=serialized,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/faq/status", response_model=FaqStatusResponse)
|
||||
async def get_faq_status(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True),
|
||||
) -> FaqStatusResponse:
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
|
||||
|
||||
if not setting:
|
||||
return FaqStatusResponse(
|
||||
requested_language=requested_lang,
|
||||
language=requested_lang,
|
||||
is_enabled=False,
|
||||
)
|
||||
|
||||
return FaqStatusResponse(
|
||||
requested_language=requested_lang,
|
||||
language=setting.language,
|
||||
is_enabled=bool(setting.is_enabled),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/faq/status", response_model=FaqStatusResponse)
|
||||
async def update_faq_status(
|
||||
payload: FaqStatusUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FaqStatusResponse:
|
||||
lang = FaqService.normalize_language(payload.language)
|
||||
setting = await FaqService.set_enabled(db, lang, payload.is_enabled)
|
||||
|
||||
return FaqStatusResponse(
|
||||
requested_language=lang,
|
||||
language=setting.language,
|
||||
is_enabled=bool(setting.is_enabled),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/service-rules", response_model=ServiceRulesResponse)
|
||||
async def get_service_rules(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
fallback: bool = Query(True),
|
||||
) -> ServiceRulesResponse:
|
||||
requested_lang = language.split("-")[0].lower()
|
||||
rules = await get_rules_by_language(db, requested_lang)
|
||||
|
||||
if not rules and fallback:
|
||||
default_lang = (settings.DEFAULT_LANGUAGE or "ru").split("-")[0].lower()
|
||||
if default_lang != requested_lang:
|
||||
rules = await get_rules_by_language(db, default_lang)
|
||||
|
||||
if not rules:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Service rules not found")
|
||||
|
||||
return _serialize_rules(rules)
|
||||
|
||||
|
||||
@router.put("/service-rules", response_model=ServiceRulesResponse)
|
||||
async def update_service_rules(
|
||||
payload: ServiceRulesUpdateRequest,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> ServiceRulesResponse:
|
||||
lang = payload.language.split("-")[0].lower()
|
||||
title = payload.title or "Правила сервиса"
|
||||
rules = await create_or_update_rules(
|
||||
db,
|
||||
content=payload.content,
|
||||
language=lang,
|
||||
title=title,
|
||||
)
|
||||
|
||||
return _serialize_rules(rules)
|
||||
|
||||
|
||||
@router.delete("/service-rules", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def clear_service_rules(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
) -> Response:
|
||||
lang = language.split("-")[0].lower()
|
||||
await clear_all_rules(db, lang)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/service-rules/history", response_model=ServiceRulesHistoryResponse)
|
||||
async def get_service_rules_history(
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
) -> ServiceRulesHistoryResponse:
|
||||
lang = language.split("-")[0].lower()
|
||||
history = await get_all_rules_versions(db, lang, limit=limit)
|
||||
items = [_serialize_rules(item) for item in history]
|
||||
return ServiceRulesHistoryResponse(
|
||||
language=lang,
|
||||
total=len(items),
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/service-rules/history/{rule_id}/restore",
|
||||
response_model=ServiceRulesResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def restore_service_rules_version(
|
||||
rule_id: int,
|
||||
_: object = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
) -> ServiceRulesResponse:
|
||||
lang = language.split("-")[0].lower()
|
||||
restored = await restore_rules_version(db, rule_id, language=lang)
|
||||
if not restored:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Rules version not found")
|
||||
return _serialize_rules(restored)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
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.database.crud.discount_offer import (
|
||||
count_discount_offers,
|
||||
get_offer_by_id,
|
||||
list_discount_offers,
|
||||
upsert_discount_offer,
|
||||
)
|
||||
from app.database.crud.promo_offer_log import list_promo_offer_logs
|
||||
from app.database.crud.promo_offer_template import (
|
||||
get_promo_offer_template_by_id,
|
||||
list_promo_offer_templates,
|
||||
update_promo_offer_template,
|
||||
)
|
||||
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, Subscription, User
|
||||
|
||||
from ..dependencies import get_db_session, require_api_token
|
||||
from ..schemas.promo_offers import (
|
||||
PromoOfferCreateRequest,
|
||||
PromoOfferListResponse,
|
||||
PromoOfferLogListResponse,
|
||||
PromoOfferLogOfferInfo,
|
||||
PromoOfferLogResponse,
|
||||
PromoOfferResponse,
|
||||
PromoOfferSubscriptionInfo,
|
||||
PromoOfferTemplateListResponse,
|
||||
PromoOfferTemplateResponse,
|
||||
PromoOfferTemplateUpdateRequest,
|
||||
PromoOfferUserInfo,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]:
|
||||
if not user:
|
||||
return None
|
||||
|
||||
return PromoOfferUserInfo(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=getattr(user, "full_name", None),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[PromoOfferSubscriptionInfo]:
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
return PromoOfferSubscriptionInfo(
|
||||
id=subscription.id,
|
||||
status=subscription.status,
|
||||
is_trial=subscription.is_trial,
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse:
|
||||
return PromoOfferResponse(
|
||||
id=offer.id,
|
||||
user_id=offer.user_id,
|
||||
subscription_id=offer.subscription_id,
|
||||
notification_type=offer.notification_type,
|
||||
discount_percent=offer.discount_percent,
|
||||
bonus_amount_kopeks=offer.bonus_amount_kopeks,
|
||||
expires_at=offer.expires_at,
|
||||
claimed_at=offer.claimed_at,
|
||||
is_active=offer.is_active,
|
||||
effect_type=offer.effect_type,
|
||||
extra_data=offer.extra_data or {},
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
user=_serialize_user(getattr(offer, "user", None)),
|
||||
subscription=_serialize_subscription(getattr(offer, "subscription", None)),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateResponse:
|
||||
return PromoOfferTemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
offer_type=template.offer_type,
|
||||
message_text=template.message_text,
|
||||
button_text=template.button_text,
|
||||
valid_hours=template.valid_hours,
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=template.bonus_amount_kopeks,
|
||||
active_discount_hours=template.active_discount_hours,
|
||||
test_duration_hours=template.test_duration_hours,
|
||||
test_squad_uuids=[str(uuid) for uuid in (template.test_squad_uuids or [])],
|
||||
is_active=template.is_active,
|
||||
created_by=template.created_by,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_log_response(entry: PromoOfferLog) -> PromoOfferLogResponse:
|
||||
user_info = _serialize_user(getattr(entry, "user", None))
|
||||
|
||||
offer = getattr(entry, "offer", None)
|
||||
offer_info: Optional[PromoOfferLogOfferInfo] = None
|
||||
if offer:
|
||||
offer_info = PromoOfferLogOfferInfo(
|
||||
id=offer.id,
|
||||
notification_type=offer.notification_type,
|
||||
discount_percent=offer.discount_percent,
|
||||
bonus_amount_kopeks=offer.bonus_amount_kopeks,
|
||||
effect_type=offer.effect_type,
|
||||
expires_at=offer.expires_at,
|
||||
claimed_at=offer.claimed_at,
|
||||
is_active=offer.is_active,
|
||||
)
|
||||
|
||||
return PromoOfferLogResponse(
|
||||
id=entry.id,
|
||||
user_id=entry.user_id,
|
||||
offer_id=entry.offer_id,
|
||||
action=entry.action,
|
||||
source=entry.source,
|
||||
percent=entry.percent,
|
||||
effect_type=entry.effect_type,
|
||||
details=entry.details or {},
|
||||
created_at=entry.created_at,
|
||||
user=user_info,
|
||||
offer=offer_info,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=PromoOfferListResponse)
|
||||
async def list_promo_offers(
|
||||
_: 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(None, ge=1),
|
||||
notification_type: Optional[str] = Query(None, min_length=1),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
) -> PromoOfferListResponse:
|
||||
offers = await list_discount_offers(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
notification_type=notification_type,
|
||||
is_active=is_active,
|
||||
)
|
||||
total = await count_discount_offers(
|
||||
db,
|
||||
user_id=user_id,
|
||||
notification_type=notification_type,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
return PromoOfferListResponse(
|
||||
items=[_serialize_offer(offer) for offer in offers],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=PromoOfferResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promo_offer(
|
||||
payload: PromoOfferCreateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferResponse:
|
||||
if payload.discount_percent < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
|
||||
if payload.bonus_amount_kopeks < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
|
||||
if payload.valid_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
|
||||
if not payload.notification_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "notification_type must not be empty")
|
||||
if not payload.effect_type.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty")
|
||||
|
||||
user = await db.get(User, payload.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
if payload.subscription_id is not None:
|
||||
subscription = await db.get(Subscription, payload.subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
|
||||
if subscription.user_id != payload.user_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription does not belong to the user")
|
||||
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
subscription_id=payload.subscription_id,
|
||||
notification_type=payload.notification_type.strip(),
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
valid_hours=payload.valid_hours,
|
||||
effect_type=payload.effect_type,
|
||||
extra_data=payload.extra_data,
|
||||
)
|
||||
|
||||
await db.refresh(offer, attribute_names=["user", "subscription"])
|
||||
|
||||
return _serialize_offer(offer)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=PromoOfferLogListResponse)
|
||||
async def get_promo_offer_logs(
|
||||
_: 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(None, ge=1),
|
||||
offer_id: Optional[int] = Query(None, ge=1),
|
||||
action: Optional[str] = Query(None, min_length=1),
|
||||
source: Optional[str] = Query(None, min_length=1),
|
||||
) -> PromoOfferLogListResponse:
|
||||
logs, total = await list_promo_offer_logs(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
offer_id=offer_id,
|
||||
action=action,
|
||||
source=source,
|
||||
)
|
||||
|
||||
return PromoOfferLogListResponse(
|
||||
items=[_build_log_response(entry) for entry in logs],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=PromoOfferTemplateListResponse)
|
||||
async def list_promo_offer_templates_endpoint(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferTemplateListResponse:
|
||||
templates = await list_promo_offer_templates(db)
|
||||
return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates])
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
|
||||
async def get_promo_offer_template_endpoint(
|
||||
template_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found")
|
||||
return _serialize_template(template)
|
||||
|
||||
|
||||
@router.patch("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
|
||||
async def update_promo_offer_template_endpoint(
|
||||
template_id: int,
|
||||
payload: PromoOfferTemplateUpdateRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found")
|
||||
|
||||
if payload.valid_hours is not None and payload.valid_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive")
|
||||
if payload.active_discount_hours is not None and payload.active_discount_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "active_discount_hours must be positive")
|
||||
if payload.test_duration_hours is not None and payload.test_duration_hours <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "test_duration_hours must be positive")
|
||||
if payload.discount_percent is not None and payload.discount_percent < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative")
|
||||
if payload.bonus_amount_kopeks is not None and payload.bonus_amount_kopeks < 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative")
|
||||
|
||||
if payload.test_squad_uuids is not None:
|
||||
normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()]
|
||||
else:
|
||||
normalized_squads = None
|
||||
|
||||
updated_template = await update_promo_offer_template(
|
||||
db,
|
||||
template,
|
||||
name=payload.name,
|
||||
message_text=payload.message_text,
|
||||
button_text=payload.button_text,
|
||||
valid_hours=payload.valid_hours,
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
active_discount_hours=payload.active_discount_hours,
|
||||
test_duration_hours=payload.test_duration_hours,
|
||||
test_squad_uuids=normalized_squads,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
|
||||
return _serialize_template(updated_template)
|
||||
|
||||
|
||||
@router.get("/{offer_id}", response_model=PromoOfferResponse)
|
||||
async def get_promo_offer_endpoint(
|
||||
offer_id: int,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> PromoOfferResponse:
|
||||
offer = await get_offer_by_id(db, offer_id)
|
||||
if not offer:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer not found")
|
||||
|
||||
return _serialize_offer(offer)
|
||||
@@ -131,6 +131,7 @@ async def create_subscription(
|
||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
|
||||
connected_squads=payload.connected_squads or [],
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
|
||||
@@ -45,6 +45,7 @@ class SettingDefinition(BaseModel):
|
||||
current: Any | None = Field(default=None)
|
||||
original: Any | None = Field(default=None)
|
||||
has_override: bool
|
||||
read_only: bool = Field(default=False)
|
||||
choices: list[SettingChoice] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from app.database.models import MainMenuButtonActionType, MainMenuButtonVisibility
|
||||
|
||||
|
||||
def _clean_text(value: str) -> str:
|
||||
cleaned = (value or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Text cannot be empty")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _validate_action_value(value: str) -> str:
|
||||
cleaned = (value or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Action value cannot be empty")
|
||||
if not cleaned.lower().startswith(("http://", "https://")):
|
||||
raise ValueError("Action value must start with http:// or https://")
|
||||
return cleaned
|
||||
|
||||
|
||||
class MainMenuButtonResponse(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
action_type: MainMenuButtonActionType
|
||||
action_value: str
|
||||
visibility: MainMenuButtonVisibility
|
||||
is_active: bool
|
||||
display_order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class MainMenuButtonCreateRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=64)
|
||||
action_type: MainMenuButtonActionType
|
||||
action_value: str = Field(..., min_length=1, max_length=1024)
|
||||
visibility: MainMenuButtonVisibility = MainMenuButtonVisibility.ALL
|
||||
is_active: bool = True
|
||||
display_order: Optional[int] = Field(None, ge=0)
|
||||
|
||||
_normalize_text = validator("text", allow_reuse=True)(_clean_text)
|
||||
_normalize_action_value = validator("action_value", allow_reuse=True)(_validate_action_value)
|
||||
|
||||
|
||||
class MainMenuButtonUpdateRequest(BaseModel):
|
||||
text: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
action_type: Optional[MainMenuButtonActionType] = None
|
||||
action_value: Optional[str] = Field(None, min_length=1, max_length=1024)
|
||||
visibility: Optional[MainMenuButtonVisibility] = None
|
||||
is_active: Optional[bool] = None
|
||||
display_order: Optional[int] = Field(None, ge=0)
|
||||
|
||||
@validator("text")
|
||||
def validate_text(cls, value): # noqa: D401,B902
|
||||
if value is None:
|
||||
return value
|
||||
return _clean_text(value)
|
||||
|
||||
@validator("action_value")
|
||||
def validate_action_value(cls, value): # noqa: D401,B902
|
||||
if value is None:
|
||||
return value
|
||||
return _validate_action_value(value)
|
||||
|
||||
|
||||
class MainMenuButtonListResponse(BaseModel):
|
||||
items: list[MainMenuButtonResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -34,11 +34,34 @@ class MiniAppSubscriptionUser(BaseModel):
|
||||
traffic_limit_label: str
|
||||
lifetime_used_traffic_gb: float = 0.0
|
||||
has_active_subscription: bool = False
|
||||
promo_offer_discount_percent: int = 0
|
||||
promo_offer_discount_expires_at: Optional[datetime] = None
|
||||
promo_offer_discount_source: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppPromoGroup(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
period_discounts: Dict[int, int] = Field(default_factory=dict)
|
||||
apply_discounts_to_addons: bool = True
|
||||
|
||||
|
||||
class MiniAppAutoPromoGroupLevel(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
threshold_kopeks: int
|
||||
threshold_rubles: float
|
||||
threshold_label: str
|
||||
is_reached: bool = False
|
||||
is_current: bool = False
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
period_discounts: Dict[int, int] = Field(default_factory=dict)
|
||||
apply_discounts_to_addons: bool = True
|
||||
|
||||
|
||||
class MiniAppConnectedServer(BaseModel):
|
||||
@@ -47,6 +70,7 @@ class MiniAppConnectedServer(BaseModel):
|
||||
|
||||
|
||||
class MiniAppDevice(BaseModel):
|
||||
hwid: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
device_model: Optional[str] = None
|
||||
app_version: Optional[str] = None
|
||||
@@ -54,6 +78,16 @@ class MiniAppDevice(BaseModel):
|
||||
last_ip: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppDeviceRemovalRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
hwid: str
|
||||
|
||||
|
||||
class MiniAppDeviceRemovalResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppTransaction(BaseModel):
|
||||
id: int
|
||||
type: str
|
||||
@@ -67,6 +101,158 @@ class MiniAppTransaction(BaseModel):
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppPromoOffer(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
notification_type: Optional[str] = None
|
||||
offer_type: Optional[str] = None
|
||||
effect_type: Optional[str] = None
|
||||
discount_percent: int = 0
|
||||
bonus_amount_kopeks: int = 0
|
||||
bonus_amount_label: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
claimed_at: Optional[datetime] = None
|
||||
is_active: bool = False
|
||||
template_id: Optional[int] = None
|
||||
template_name: Optional[str] = None
|
||||
button_text: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
message_text: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
test_squads: List[MiniAppConnectedServer] = Field(default_factory=list)
|
||||
active_discount_expires_at: Optional[datetime] = None
|
||||
active_discount_started_at: Optional[datetime] = None
|
||||
active_discount_duration_seconds: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppPromoOfferClaimRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
|
||||
class MiniAppPromoOfferClaimResponse(BaseModel):
|
||||
success: bool = True
|
||||
code: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppPromoCode(BaseModel):
|
||||
code: str
|
||||
type: Optional[str] = None
|
||||
balance_bonus_kopeks: int = 0
|
||||
subscription_days: int = 0
|
||||
max_uses: Optional[int] = None
|
||||
current_uses: Optional[int] = None
|
||||
valid_until: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppPromoCodeActivationRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
code: str
|
||||
|
||||
|
||||
class MiniAppPromoCodeActivationResponse(BaseModel):
|
||||
success: bool = True
|
||||
description: Optional[str] = None
|
||||
promocode: Optional[MiniAppPromoCode] = None
|
||||
|
||||
|
||||
class MiniAppFaqItem(BaseModel):
|
||||
id: int
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
display_order: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppFaq(BaseModel):
|
||||
requested_language: str
|
||||
language: str
|
||||
is_enabled: bool = True
|
||||
total: int = 0
|
||||
items: List[MiniAppFaqItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppRichTextDocument(BaseModel):
|
||||
requested_language: str
|
||||
language: str
|
||||
title: Optional[str] = None
|
||||
is_enabled: bool = True
|
||||
content: str = ""
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppLegalDocuments(BaseModel):
|
||||
public_offer: Optional[MiniAppRichTextDocument] = None
|
||||
service_rules: Optional[MiniAppRichTextDocument] = None
|
||||
privacy_policy: Optional[MiniAppRichTextDocument] = None
|
||||
|
||||
|
||||
class MiniAppReferralTerms(BaseModel):
|
||||
minimum_topup_kopeks: int = 0
|
||||
minimum_topup_label: Optional[str] = None
|
||||
first_topup_bonus_kopeks: int = 0
|
||||
first_topup_bonus_label: Optional[str] = None
|
||||
inviter_bonus_kopeks: int = 0
|
||||
inviter_bonus_label: Optional[str] = None
|
||||
commission_percent: float = 0.0
|
||||
referred_user_reward_kopeks: int = 0
|
||||
referred_user_reward_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppReferralStats(BaseModel):
|
||||
invited_count: int = 0
|
||||
paid_referrals_count: int = 0
|
||||
active_referrals_count: int = 0
|
||||
total_earned_kopeks: int = 0
|
||||
total_earned_label: Optional[str] = None
|
||||
month_earned_kopeks: int = 0
|
||||
month_earned_label: Optional[str] = None
|
||||
conversion_rate: float = 0.0
|
||||
|
||||
|
||||
class MiniAppReferralRecentEarning(BaseModel):
|
||||
amount_kopeks: int = 0
|
||||
amount_label: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
referral_name: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class MiniAppReferralItem(BaseModel):
|
||||
id: int
|
||||
telegram_id: Optional[int] = None
|
||||
full_name: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
last_activity: Optional[datetime] = None
|
||||
has_made_first_topup: bool = False
|
||||
balance_kopeks: int = 0
|
||||
balance_label: Optional[str] = None
|
||||
total_earned_kopeks: int = 0
|
||||
total_earned_label: Optional[str] = None
|
||||
topups_count: int = 0
|
||||
days_since_registration: Optional[int] = None
|
||||
days_since_activity: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppReferralList(BaseModel):
|
||||
total_count: int = 0
|
||||
has_next: bool = False
|
||||
has_prev: bool = False
|
||||
current_page: int = 1
|
||||
total_pages: int = 1
|
||||
items: List[MiniAppReferralItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MiniAppReferralInfo(BaseModel):
|
||||
referral_code: Optional[str] = None
|
||||
referral_link: Optional[str] = None
|
||||
terms: Optional[MiniAppReferralTerms] = None
|
||||
stats: Optional[MiniAppReferralStats] = None
|
||||
recent_earnings: List[MiniAppReferralRecentEarning] = Field(default_factory=list)
|
||||
referrals: Optional[MiniAppReferralList] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionResponse(BaseModel):
|
||||
success: bool = True
|
||||
subscription_id: int
|
||||
@@ -89,8 +275,16 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
balance_rubles: float = 0.0
|
||||
balance_currency: Optional[str] = None
|
||||
transactions: List[MiniAppTransaction] = Field(default_factory=list)
|
||||
promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list)
|
||||
promo_group: Optional[MiniAppPromoGroup] = None
|
||||
auto_assign_promo_groups: List[MiniAppAutoPromoGroupLevel] = Field(default_factory=list)
|
||||
total_spent_kopeks: int = 0
|
||||
total_spent_rubles: float = 0.0
|
||||
total_spent_label: Optional[str] = None
|
||||
subscription_type: str
|
||||
autopay_enabled: bool = False
|
||||
branding: Optional[MiniAppBranding] = None
|
||||
faq: Optional[MiniAppFaq] = None
|
||||
legal_documents: Optional[MiniAppLegalDocuments] = None
|
||||
referral: Optional[MiniAppReferralInfo] = None
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RichTextPageResponse(BaseModel):
|
||||
"""Generic representation for rich text informational pages."""
|
||||
|
||||
requested_language: str = Field(..., description="Язык, запрошенный клиентом")
|
||||
language: str = Field(..., description="Фактический язык найденной записи")
|
||||
is_enabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Текущий статус публикации страницы (если применимо)",
|
||||
)
|
||||
content: str = Field(..., description="Полное содержимое страницы")
|
||||
content_pages: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Содержимое, разбитое на страницы фиксированной длины",
|
||||
)
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Дата создания записи",
|
||||
)
|
||||
updated_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Дата последнего обновления записи",
|
||||
)
|
||||
|
||||
|
||||
class RichTextPageUpdateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык, для которого выполняется обновление",
|
||||
)
|
||||
content: str = Field(..., description="Новое содержимое страницы")
|
||||
is_enabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Если указано — обновить статус публикации",
|
||||
)
|
||||
|
||||
|
||||
class FaqPageResponse(BaseModel):
|
||||
id: int
|
||||
language: str
|
||||
title: str
|
||||
content: str
|
||||
content_pages: List[str] = Field(default_factory=list)
|
||||
display_order: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FaqPageListResponse(BaseModel):
|
||||
requested_language: str
|
||||
language: str
|
||||
is_enabled: bool
|
||||
total: int
|
||||
items: List[FaqPageResponse]
|
||||
|
||||
|
||||
class FaqPageCreateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык создаваемой страницы",
|
||||
)
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = Field(...)
|
||||
display_order: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description="Порядок отображения (если не указан — будет рассчитан автоматически)",
|
||||
)
|
||||
is_active: Optional[bool] = Field(
|
||||
default=True,
|
||||
description="Начальный статус активности страницы",
|
||||
)
|
||||
|
||||
|
||||
class FaqPageUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
content: Optional[str] = None
|
||||
display_order: Optional[int] = Field(default=None, ge=0)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class FaqReorderItem(BaseModel):
|
||||
id: int = Field(..., ge=1)
|
||||
display_order: int = Field(..., ge=0)
|
||||
|
||||
|
||||
class FaqReorderRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык, для которого применяется сортировка",
|
||||
)
|
||||
items: List[FaqReorderItem]
|
||||
|
||||
|
||||
class FaqStatusResponse(BaseModel):
|
||||
requested_language: str
|
||||
language: str
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
class FaqStatusUpdateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
)
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
class ServiceRulesResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
language: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ServiceRulesUpdateRequest(BaseModel):
|
||||
language: str = Field(
|
||||
default="ru",
|
||||
min_length=2,
|
||||
max_length=10,
|
||||
description="Язык, для которого обновляются правила",
|
||||
)
|
||||
title: Optional[str] = Field(
|
||||
default="Правила сервиса",
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
)
|
||||
content: str = Field(...)
|
||||
|
||||
|
||||
class ServiceRulesHistoryResponse(BaseModel):
|
||||
language: str
|
||||
total: int
|
||||
items: List[ServiceRulesResponse]
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromoOfferUserInfo(BaseModel):
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class PromoOfferSubscriptionInfo(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
is_trial: bool
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
autopay_enabled: bool
|
||||
|
||||
|
||||
class PromoOfferResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
subscription_id: Optional[int] = None
|
||||
notification_type: str
|
||||
discount_percent: int
|
||||
bonus_amount_kopeks: int
|
||||
expires_at: datetime
|
||||
claimed_at: Optional[datetime] = None
|
||||
is_active: bool
|
||||
effect_type: str
|
||||
extra_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
user: Optional[PromoOfferUserInfo] = None
|
||||
subscription: Optional[PromoOfferSubscriptionInfo] = None
|
||||
|
||||
|
||||
class PromoOfferListResponse(BaseModel):
|
||||
items: List[PromoOfferResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PromoOfferCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
notification_type: str = Field(..., min_length=1)
|
||||
valid_hours: int = Field(..., ge=1, description="Срок действия предложения в часах")
|
||||
discount_percent: int = Field(0, ge=0)
|
||||
bonus_amount_kopeks: int = Field(0, ge=0)
|
||||
subscription_id: Optional[int] = None
|
||||
effect_type: str = Field("percent_discount", min_length=1)
|
||||
extra_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PromoOfferTemplateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
offer_type: str
|
||||
message_text: str
|
||||
button_text: str
|
||||
valid_hours: int
|
||||
discount_percent: int
|
||||
bonus_amount_kopeks: int
|
||||
active_discount_hours: Optional[int] = None
|
||||
test_duration_hours: Optional[int] = None
|
||||
test_squad_uuids: List[str]
|
||||
is_active: bool
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PromoOfferTemplateListResponse(BaseModel):
|
||||
items: List[PromoOfferTemplateResponse]
|
||||
|
||||
|
||||
class PromoOfferTemplateUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
message_text: Optional[str] = None
|
||||
button_text: Optional[str] = None
|
||||
valid_hours: Optional[int] = Field(None, ge=1)
|
||||
discount_percent: Optional[int] = Field(None, ge=0)
|
||||
bonus_amount_kopeks: Optional[int] = Field(None, ge=0)
|
||||
active_discount_hours: Optional[int] = Field(None, ge=1)
|
||||
test_duration_hours: Optional[int] = Field(None, ge=1)
|
||||
test_squad_uuids: Optional[List[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class PromoOfferLogOfferInfo(BaseModel):
|
||||
id: int
|
||||
notification_type: Optional[str] = None
|
||||
discount_percent: Optional[int] = None
|
||||
bonus_amount_kopeks: Optional[int] = None
|
||||
effect_type: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
claimed_at: Optional[datetime] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class PromoOfferLogResponse(BaseModel):
|
||||
id: int
|
||||
user_id: Optional[int] = None
|
||||
offer_id: Optional[int] = None
|
||||
action: str
|
||||
source: Optional[str] = None
|
||||
percent: Optional[int] = None
|
||||
effect_type: Optional[str] = None
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
user: Optional[PromoOfferUserInfo] = None
|
||||
offer: Optional[PromoOfferLogOfferInfo] = None
|
||||
|
||||
|
||||
class PromoOfferLogListResponse(BaseModel):
|
||||
items: List[PromoOfferLogResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -127,8 +127,18 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
| `POST` | `/promo-groups` | Создать промо-группу.
|
||||
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
|
||||
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
|
||||
| `GET` | `/promo-offers` | Список промо-предложений с фильтрами по пользователю, статусу и типу уведомления.
|
||||
| `POST` | `/promo-offers` | Создать или обновить персональное промо-предложение пользователю.
|
||||
| `GET` | `/promo-offers/{id}` | Детали конкретного промо-предложения.
|
||||
| `GET` | `/promo-offers/templates` | Список шаблонов промо-предложений.
|
||||
| `GET` | `/promo-offers/templates/{id}` | Получить данные шаблона промо-предложения.
|
||||
| `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона.
|
||||
| `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения).
|
||||
| `GET` | `/tokens` | Управление токенами доступа.
|
||||
|
||||
> Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку
|
||||
> текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций).
|
||||
|
||||
### RemnaWave интеграция
|
||||
|
||||
После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота:
|
||||
|
||||
+299
-5
@@ -55,6 +55,8 @@
|
||||
"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_TEST_ACCESS_HEADER": "🧪 Test servers active: {servers}",
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n<code>{bar}</code>",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"MENU_BALANCE": "💰 Balance",
|
||||
@@ -328,6 +330,34 @@
|
||||
"MENU_ADMIN": "⚙️ Admin panel",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
|
||||
"MENU_INFO": "ℹ️ Info",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Info</b>",
|
||||
"MENU_INFO_PROMPT": "Choose a section:",
|
||||
"MENU_PROMO_GROUPS_INFO": "🎯 Promo groups",
|
||||
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Promo groups</b>",
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Spent in the bot: {amount}",
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Current level: {name}",
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Current level: not unlocked yet",
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 Until level “{name}”: spend {amount} more",
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 You already have the highest discount level!",
|
||||
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Auto-assigned levels:",
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — from {amount}",
|
||||
"PROMO_GROUPS_INFO_EMPTY": "Auto-assigned promo groups are not configured yet.",
|
||||
"MENU_FAQ": "❓ FAQ",
|
||||
"FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"FAQ_PAGES_PROMPT": "Select a topic:",
|
||||
"FAQ_NOT_AVAILABLE": "FAQ is temporarily unavailable.",
|
||||
"FAQ_PAGE_UNTITLED": "Untitled",
|
||||
"FAQ_PAGE_NOT_AVAILABLE": "This FAQ page is not available.",
|
||||
"FAQ_PAGE_EMPTY": "Content for this page has not been added yet.",
|
||||
"FAQ_PAGE_TITLE": "<b>{title}</b>",
|
||||
"FAQ_PAGE_FOOTER": "Page {current} of {total}",
|
||||
"FAQ_BACK_TO_LIST": "⬅️ Back to FAQ list",
|
||||
"MENU_PRIVACY_POLICY": "🛡️ Privacy policy",
|
||||
"PRIVACY_POLICY_HEADER": "🛡️ <b>Privacy policy</b>",
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE": "The privacy policy is temporarily unavailable.",
|
||||
"PRIVACY_POLICY_EMPTY_ALERT": "The privacy policy text has not been provided yet.",
|
||||
"PRIVACY_POLICY_PAGE_INFO": "Page {current} of {total}",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
@@ -527,14 +557,26 @@
|
||||
"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!",
|
||||
"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}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nActivate the offer to get an additional discount. It stacks with your promo group and 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 and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It stacks with other discounts and will apply automatically to your next payment.",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
|
||||
"PROMO_OFFER_CLOSE": "❌ Close",
|
||||
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ You need an active subscription to use this offer.",
|
||||
"TEST_ACCESS_NO_SQUADS": "❌ Unable to determine servers for the test access. Please contact support.",
|
||||
"TEST_ACCESS_REMNAWAVE_ERROR": "❌ Failed to connect the servers. Please try again later or contact support.",
|
||||
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Failed to activate the offer. Please try again later.",
|
||||
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ This server is already connected to your subscription.",
|
||||
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Test servers are connected! Access is active until {expires_at}.",
|
||||
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Access granted!",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
|
||||
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
|
||||
@@ -557,6 +599,131 @@
|
||||
"ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:",
|
||||
"ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Welcome message",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Promo offers",
|
||||
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Promo offers</b>\n\nSelect a template to configure:",
|
||||
"ADMIN_PROMO_OFFER_TEST_ACCESS": "Test servers",
|
||||
"ADMIN_PROMO_OFFER_EXTEND": "Renewal discount",
|
||||
"ADMIN_PROMO_OFFER_PURCHASE": "Purchase discount",
|
||||
"ADMIN_PROMO_OFFER_TYPE": "Type: {label}",
|
||||
"ADMIN_PROMO_OFFER_VALID": "Validity: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_DISCOUNT": "Extra discount: {percent}% (stacks with other discounts)",
|
||||
"ADMIN_PROMO_OFFER_STACKABLE_NOTE": "The discount applies once and stacks with the promo group.",
|
||||
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "After activation the discount lasts for {hours} h.",
|
||||
"ADMIN_PROMO_OFFER_TEST_DURATION": "Access: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Server: {name}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified",
|
||||
"ADMIN_PROMO_OFFER_ALLOWED": "Available segments:",
|
||||
"ADMIN_PROMO_OFFER_PREVIEW": "Preview:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_MESSAGE": "Enter the new offer text:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Enter the button label:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Enter validity (hours):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Enter discount percentage:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_ACTIVE_DURATION": "Enter how long the activated discount should remain valid (hours):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Enter test access duration (hours):",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 <b>Select a squad</b>",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Current squad: {name}",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Current squad: {uuid}",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Current squad: not selected",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Pick a server to grant temporary access from the list below.",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Page {page}/{total}",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Clear",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Back",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Squad updated",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Squad cleared",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Server not found",
|
||||
"ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ No servers available.",
|
||||
"ADMIN_PROMO_OFFER_SENDING": "Starting broadcast...",
|
||||
"ADMIN_PROMO_OFFER_NO_USERS": "No matching users found.",
|
||||
"ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}",
|
||||
"ADMIN_PROMO_OFFER_SKIPPED": "Skipped: {skipped} (already have access)",
|
||||
"ADMIN_PROMO_OFFER_CTA_BUY": "Buy subscription",
|
||||
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Extend subscription",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ Back to offer",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ Back to promo offers",
|
||||
"ADMIN_PROMO_OFFER_LOGS": "📜 Activity log",
|
||||
"ADMIN_PROMO_OFFER_LOGS_TITLE": "📜 <b>Promo offer activity log</b>",
|
||||
"ADMIN_PROMO_OFFER_LOGS_PAGINATION": "Page {page}/{total}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY": "No activity yet.",
|
||||
"ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER": "Unknown user",
|
||||
"ADMIN_PROMO_OFFER_LOGS_USER": "👤 {user}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_PERCENT": "📉 Discount: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST": "🧪 Test access",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT": "💸 Discount",
|
||||
"ADMIN_PROMO_OFFER_LOGS_SOURCE": "🏷 Source: {source}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC": "ℹ️ Action: {reason}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL": "💳 Applied during manual payment",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY": "🤖 Used in autopay renewal",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED": "⏳ Offer expired",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED": "⏳ Test access removed",
|
||||
"ADMIN_PROMO_OFFER_LOGS_DESCRIPTION": "📝 {description}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_AMOUNT": "💰 Amount: {amount}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_SQUAD": "🌍 Squad: {squad}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS": "🌍 New squads: {squads}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Claimed",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Used",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Disabled",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER": "👤 Send to user",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TITLE": "👤 <b>Send to a user</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_HINT": "Select a user to deliver the promo offer.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH": "🔍 Search",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT": "Enter name, username or user ID to search:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY": "🔍 Search: <b>{query}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_RESET": "❌ Clear search",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS": "↩️ Back to segment selection",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY": "No matching users found. Adjust your query.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_PROFILE": "👤 <b>{name}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM": "🆔 <code>{telegram_id}</code>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_USERNAME": "🔗 @{username}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_STATUS": "Status: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BALANCE": "Balance: {amount}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION": "💳 <b>Subscription</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS": "Status: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END": "Expires: {date}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN": "not specified",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC": "Traffic: {used}/{limit} GB",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS": "Connected squads: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION": "💳 No active subscription",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT": "💸 Active discount: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL": " (until {date})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT": " (until {date}, {time} left)",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE": " — source: {source}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE": "💸 No active discount",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER": "📨 <b>Selected offer</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE": "Type: {label}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID": "Valid for: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION": "Test access: {hours} h",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT": "Discount: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION": "Active after claim for {hours} h",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS": "📨 Active offers:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST": "Test access",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT": "{percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS": "+{bonus}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY": "no expiry",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM": "• {description} (until {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER": "• {description}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES": " Expires: {expires}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS": " Status: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED": "✅ Claimed",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING": "⏳ Not claimed",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT": " Time left: {time}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED": "expired",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION": " After claim: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION": " Total validity: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER": "📊 Offer stats",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL": "Total sent: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED": "Claimed: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING": "Not claimed: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE": "Active now: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS": "🧪 Active test accesses:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM": "• {squad} (until {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS": "📨 No active offers",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON": "📬 Send offer",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST": "⬅️ Back to users",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE": "👤 Back to profile",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE": "📬 Sent to {name}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SKIPPED": "Skipped: {skipped} (already has access)",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT": "Delivery not performed",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Support tickets",
|
||||
"ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit",
|
||||
"ADMIN_SUPPORT_SETTINGS": "🛟 Support settings",
|
||||
@@ -611,6 +778,37 @@
|
||||
"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_SETTINGS_PRIVACY_POLICY": "🛡️ Privacy policy",
|
||||
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Privacy policy</b>",
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "The privacy policy is shown in the Info section.",
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Policy display is disabled or empty.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Policy is active and visible to users.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Policy is enabled but the text is empty — users won't see it.",
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Last updated: {timestamp}",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Text preview:</b>",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "No text provided yet.",
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Edit text",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 View current text",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Current text (preview):",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Send the new privacy policy text. HTML markup is allowed.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Use /html_help for supported tags.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Privacy policy editing",
|
||||
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Cancel",
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ The policy text is too long. Maximum 4000 characters.",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Privacy policy updated.",
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ Back to policy settings",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "The privacy policy text is not set yet.",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Current policy text</b>",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ The text is shortened for display. Users will see the full version in the menu.",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Policy enabled",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Policy disabled",
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Back to editing",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
|
||||
"ADMIN_SETTINGS_BOT_CONFIG": "🧩 Bot configuration",
|
||||
@@ -834,6 +1032,7 @@
|
||||
"ADMIN_PRICING_BUTTON_PERIOD_OPTIONS": "🗓 Available periods",
|
||||
"ADMIN_PRICING_BUTTON_PERIODS": "🗓 Subscription periods",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC": "📦 Traffic packages",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Package visibility",
|
||||
"ADMIN_PRICING_BUTTON_TRIAL": "🎁 Trial period",
|
||||
"ADMIN_PRICING_CHOICE_ALREADY": "This option is already active.",
|
||||
"ADMIN_PRICING_CHOICE_UPDATED": "Selected: {label}",
|
||||
@@ -850,6 +1049,9 @@
|
||||
"ADMIN_PRICING_PERIOD_DISABLED": "Period disabled.",
|
||||
"ADMIN_PRICING_PERIOD_ENABLED": "Period enabled.",
|
||||
"ADMIN_PRICING_PERIOD_MIN": "At least one period must remain.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Package enabled.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Package disabled.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "At least one package must remain.",
|
||||
"ADMIN_PRICING_SECTION_CORE_TITLE": "⚙️ Core limits",
|
||||
"ADMIN_PRICING_SECTION_CURRENT": "Current values:",
|
||||
"ADMIN_PRICING_SECTION_EMPTY": "No values available.",
|
||||
@@ -862,6 +1064,10 @@
|
||||
"ADMIN_PRICING_SECTION_PROMPT": "Select what to update:",
|
||||
"ADMIN_PRICING_SECTION_SETTINGS_GENERIC": "⚙️ Settings",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_TITLE": "📦 Traffic packages",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Traffic package visibility",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Active packages: {items}",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "No active packages.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Tap a package to toggle its visibility.",
|
||||
"ADMIN_PRICING_SECTION_TRIAL_TITLE": "🎁 Trial period",
|
||||
"ADMIN_PRICING_SETTING_CANCEL_HINT": "Reply \"Cancel\" to go back without changes.",
|
||||
"ADMIN_PRICING_SETTING_CURRENT": "Current value",
|
||||
@@ -874,5 +1080,93 @@
|
||||
"ADMIN_PRICING_SETTING_PROMPT": "Send a new value or type \"Cancel\". Use none to clear.",
|
||||
"ADMIN_PRICING_SETTING_SUCCESS": "Parameter {label} updated: {value}",
|
||||
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Important"
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Important",
|
||||
"MENU_PUBLIC_OFFER": "📄 Offer",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Public Offer</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
|
||||
"ADMIN_SETTINGS_FAQ": "❓ FAQ",
|
||||
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Public offer</b>",
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Text preview:</b>",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"ADMIN_FAQ_DESCRIPTION": "FAQ is shown in the “Info” section.",
|
||||
"ADMIN_FAQ_LANGUAGE": "Language: <code>{lang}</code>",
|
||||
"ADMIN_FAQ_PAGE_STATS": "Total pages: {total}",
|
||||
"ADMIN_FAQ_STATUS_DISABLED": "⚠️ FAQ display is turned off.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED": "✅ FAQ is enabled. Active pages: {count}.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED_EMPTY": "⚠️ FAQ is enabled but there are no active pages.",
|
||||
"ADMIN_FAQ_STATUS_EMPTY": "⚠️ FAQ has not been configured yet.",
|
||||
"ADMIN_FAQ_PAGES_EMPTY": "No pages have been created yet.",
|
||||
"ADMIN_FAQ_PAGES_OVERVIEW": "<b>Page list:</b>\n{items}",
|
||||
"ADMIN_FAQ_ACTION_PROMPT": "Choose an action:",
|
||||
"ADMIN_FAQ_ADD_PAGE_BUTTON": "➕ Add page",
|
||||
"ADMIN_FAQ_ENABLE_BUTTON": "✅ Enable display",
|
||||
"ADMIN_FAQ_DISABLE_BUTTON": "🚫 Disable display",
|
||||
"ADMIN_FAQ_HTML_HELP": "ℹ️ HTML help",
|
||||
"ADMIN_FAQ_ENTER_TITLE": "Enter a title for the new FAQ page:",
|
||||
"ADMIN_FAQ_CANCEL_BUTTON": "⬅️ Cancel",
|
||||
"ADMIN_FAQ_TITLE_EMPTY": "❌ Title cannot be empty.",
|
||||
"ADMIN_FAQ_TITLE_TOO_LONG": "❌ Title is too long. Maximum 255 characters.",
|
||||
"ADMIN_FAQ_ENTER_CONTENT": "Send the content of the FAQ page. HTML is allowed.",
|
||||
"ADMIN_FAQ_CONTENT_TOO_LONG": "❌ Text is too long. Maximum 6000 characters.",
|
||||
"ADMIN_FAQ_CONTENT_EMPTY": "❌ Text cannot be empty.",
|
||||
"ADMIN_FAQ_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_FAQ_PAGE_CREATED": "✅ FAQ page created.",
|
||||
"ADMIN_FAQ_BACK_TO_LIST": "⬅️ Back to FAQ settings",
|
||||
"ADMIN_FAQ_ENABLED_ALERT": "✅ FAQ enabled.",
|
||||
"ADMIN_FAQ_DISABLED_ALERT": "🚫 FAQ disabled.",
|
||||
"ADMIN_FAQ_PAGE_NOT_FOUND": "⚠️ Page not found.",
|
||||
"ADMIN_FAQ_PAGE_HEADER": "📄 <b>FAQ page</b>",
|
||||
"ADMIN_FAQ_PAGE_STATUS_ACTIVE": "✅ Active",
|
||||
"ADMIN_FAQ_PAGE_STATUS_INACTIVE": "🚫 Disabled",
|
||||
"ADMIN_FAQ_PAGE_UPDATED": "Updated: {timestamp}",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW_EMPTY": "Content has not been provided yet.",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW": "<b>Preview:</b>\n{content}",
|
||||
"ADMIN_FAQ_PAGE_TITLE": "<b>Title:</b> {title}",
|
||||
"ADMIN_FAQ_PAGE_STATUS": "Status: {status}",
|
||||
"ADMIN_FAQ_EDIT_TITLE_BUTTON": "✏️ Edit title",
|
||||
"ADMIN_FAQ_EDIT_CONTENT_BUTTON": "📝 Edit text",
|
||||
"ADMIN_FAQ_PAGE_ENABLE_BUTTON": "✅ Enable page",
|
||||
"ADMIN_FAQ_PAGE_DISABLE_BUTTON": "🚫 Disable page",
|
||||
"ADMIN_FAQ_PAGE_MOVE_UP": "⬆️ Up",
|
||||
"ADMIN_FAQ_PAGE_MOVE_DOWN": "⬇️ Down",
|
||||
"ADMIN_FAQ_PAGE_DELETE_BUTTON": "🗑️ Delete",
|
||||
"ADMIN_FAQ_TITLE_UPDATED": "✅ Title updated.",
|
||||
"ADMIN_FAQ_UNEXPECTED_STATE": "⚠️ State was reset.",
|
||||
"ADMIN_FAQ_CONTENT_UPDATED": "✅ Page text updated.",
|
||||
"ADMIN_FAQ_PAGE_ENABLED_ALERT": "✅ Page enabled.",
|
||||
"ADMIN_FAQ_PAGE_DISABLED_ALERT": "🚫 Page disabled.",
|
||||
"ADMIN_FAQ_PAGE_DELETED": "🗑️ Page deleted.",
|
||||
"ADMIN_FAQ_PAGE_REORDERED": "✅ Order updated.",
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
|
||||
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
|
||||
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Current offer text</b>",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled."
|
||||
}
|
||||
|
||||
+301
-5
@@ -235,12 +235,42 @@
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {servers}",
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n<code>{bar}</code>",
|
||||
"MAIN_MENU_BUTTON": "🏠 Главное меню",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
|
||||
"MENU_ADMIN": "⚙️ Админ-панель",
|
||||
"MENU_BALANCE": "💰 Баланс",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
|
||||
"MENU_INFO": "ℹ️ Инфо",
|
||||
"MENU_INFO_HEADER": "ℹ️ <b>Инфо</b>",
|
||||
"MENU_INFO_PROMPT": "Выберите раздел:",
|
||||
"MENU_PROMO_GROUPS_INFO": "🎯 Скидки за траты",
|
||||
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Скидки за траты</b>",
|
||||
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Потрачено в боте: {amount}",
|
||||
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Текущий уровень: {name}",
|
||||
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Текущий уровень: пока не получен",
|
||||
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 До уровня «{name}»: осталось {amount}",
|
||||
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 Вы уже получили максимальный уровень скидок!",
|
||||
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Уровни с автовыдачей:",
|
||||
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — от {amount}",
|
||||
"PROMO_GROUPS_INFO_EMPTY": "Промогруппы с автовыдачей ещё не настроены.",
|
||||
"MENU_FAQ": "❓ FAQ",
|
||||
"FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"FAQ_PAGES_PROMPT": "Выберите вопрос:",
|
||||
"FAQ_NOT_AVAILABLE": "FAQ временно недоступен.",
|
||||
"FAQ_PAGE_UNTITLED": "Без названия",
|
||||
"FAQ_PAGE_NOT_AVAILABLE": "Эта страница FAQ недоступна.",
|
||||
"FAQ_PAGE_EMPTY": "Текст для этой страницы ещё не добавлен.",
|
||||
"FAQ_PAGE_TITLE": "<b>{title}</b>",
|
||||
"FAQ_PAGE_FOOTER": "Страница {current} из {total}",
|
||||
"FAQ_BACK_TO_LIST": "⬅️ К списку FAQ",
|
||||
"MENU_PRIVACY_POLICY": "🛡️ Политика конфиденциальности",
|
||||
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
|
||||
"PRIVACY_POLICY_EMPTY_ALERT": "Текст политики конфиденциальности ещё не указан.",
|
||||
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
@@ -381,6 +411,8 @@
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_SERVER_RANDOM_POOL": "🎲 Случайный из {count} серверов",
|
||||
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Тестовый сервер",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
@@ -527,14 +559,26 @@
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
|
||||
"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}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.",
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
|
||||
"DISCOUNT_CLAIM_SUCCESS": "🎉 <b>Скидка {percent}% активирована!</b> \n\nОна суммируется с другими скидками и автоматически применится при следующей оплате.",
|
||||
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
|
||||
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
|
||||
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
|
||||
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
|
||||
"PROMO_OFFER_CLOSE": "❌ Закрыть",
|
||||
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ Для активации предложения необходима действующая подписка.",
|
||||
"TEST_ACCESS_NO_SQUADS": "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
|
||||
"TEST_ACCESS_REMNAWAVE_ERROR": "❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
|
||||
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Не удалось активировать предложение. Попробуйте позже.",
|
||||
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ Этот сервер уже подключен к вашей подписке.",
|
||||
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
|
||||
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Доступ выдан!",
|
||||
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
|
||||
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
|
||||
"NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
|
||||
"NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
|
||||
@@ -557,6 +601,131 @@
|
||||
"ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:",
|
||||
"ADMIN_COMMUNICATIONS_WELCOME_TEXT": "👋 Приветственный текст",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Промо-предложения",
|
||||
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:",
|
||||
"ADMIN_PROMO_OFFER_TEST_ACCESS": "Тестовые сервера",
|
||||
"ADMIN_PROMO_OFFER_EXTEND": "Скидка на продление",
|
||||
"ADMIN_PROMO_OFFER_PURCHASE": "Скидка на покупку",
|
||||
"ADMIN_PROMO_OFFER_TYPE": "Тип: {label}",
|
||||
"ADMIN_PROMO_OFFER_VALID": "Срок действия: {hours} ч",
|
||||
"ADMIN_PROMO_OFFER_DISCOUNT": "Доп. скидка: {percent}% (суммируется с другими скидками)",
|
||||
"ADMIN_PROMO_OFFER_STACKABLE_NOTE": "Скидка применяется один раз и добавляется к промогруппе.",
|
||||
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "Скидка после активации действует {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Сервер: {name}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}",
|
||||
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны",
|
||||
"ADMIN_PROMO_OFFER_ALLOWED": "Доступные категории:",
|
||||
"ADMIN_PROMO_OFFER_PREVIEW": "Предпросмотр:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_MESSAGE": "Введите новый текст предложения:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Введите новый текст кнопки:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Укажите срок действия (в часах):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Введите размер скидки в процентах:",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_ACTIVE_DURATION": "Укажите срок действия активированной скидки (в часах):",
|
||||
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Введите длительность тестового доступа (в часах):",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE": "🌍 <b>Выберите сквад</b>",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD": "Текущий сквад: {name}",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID": "Текущий сквад: {uuid}",
|
||||
"ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY": "Текущий сквад: не выбран",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT": "Выберите сервер для тестового доступа из списка ниже.",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE": "Страница {page}/{total}",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR": "🗑 Очистить",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK": "↩️ Назад",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED": "✅ Сквад обновлён",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED": "✅ Сквад очищен",
|
||||
"ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND": "❌ Сервер не найден",
|
||||
"ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE": "❌ Доступные серверы не найдены.",
|
||||
"ADMIN_PROMO_OFFER_SENDING": "Начинаем рассылку...",
|
||||
"ADMIN_PROMO_OFFER_NO_USERS": "Подходящих пользователей не найдено.",
|
||||
"ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",
|
||||
"ADMIN_PROMO_OFFER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
|
||||
"ADMIN_PROMO_OFFER_CTA_BUY": "Купить подписку",
|
||||
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Продлить подписку",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ К предложению",
|
||||
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ К промопредложениям",
|
||||
"ADMIN_PROMO_OFFER_LOGS": "📜 Лог операций",
|
||||
"ADMIN_PROMO_OFFER_LOGS_TITLE": "📜 <b>Лог операций промо-предложений</b>",
|
||||
"ADMIN_PROMO_OFFER_LOGS_PAGINATION": "Страница {page}/{total}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY": "Записей пока нет.",
|
||||
"ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER": "Неизвестный пользователь",
|
||||
"ADMIN_PROMO_OFFER_LOGS_USER": "👤 {user}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_PERCENT": "📉 Скидка: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST": "🧪 Тестовый доступ",
|
||||
"ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT": "💸 Скидка",
|
||||
"ADMIN_PROMO_OFFER_LOGS_SOURCE": "🏷 Источник: {source}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC": "ℹ️ Действие: {reason}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL": "💳 Списано при ручной оплате",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY": "🤖 Применено при автопродлении",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED": "⏳ Предложение истекло",
|
||||
"ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED": "⏳ Тестовый доступ отключён",
|
||||
"ADMIN_PROMO_OFFER_LOGS_DESCRIPTION": "📝 {description}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_AMOUNT": "💰 Сумма: {amount}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_SQUAD": "🌍 Сквад: {squad}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS": "🌍 Новые сквады: {squads}",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED": "Принято",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED": "Использовано",
|
||||
"ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED": "Отключено",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER": "👤 Отправка пользователю",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TITLE": "👤 <b>Отправка пользователю</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_HINT": "Выберите пользователя для отправки промопредложения.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH": "🔍 Поиск",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT": "Введите имя, username или ID пользователя для поиска:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY": "🔍 Поиск: <b>{query}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_RESET": "❌ Сбросить поиск",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS": "↩️ К выбору категории",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY": "Подходящие пользователи не найдены. Измените запрос поиска.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_PROFILE": "👤 <b>{name}</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM": "🆔 <code>{telegram_id}</code>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_USERNAME": "🔗 @{username}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_STATUS": "Статус: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BALANCE": "Баланс: {amount}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION": "💳 <b>Подписка</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS": "Статус: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END": "Истекает: {date}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN": "не указано",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC": "Трафик: {used}/{limit} ГБ",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS": "Подключено сквадов: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION": "💳 Подписка отсутствует",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT": "💸 Активная скидка: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL": " (до {date})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT": " (до {date}, осталось {time})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE": " — источник: {source}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE": "💸 Активная скидка отсутствует",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER": "📨 <b>Выбранное предложение</b>",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE": "Тип: {label}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID": "Действует: {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION": "Тестовый доступ: {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT": "Скидка: {percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION": "После активации действует {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS": "📨 Активные предложения:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST": "Тестовый доступ",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT": "{percent}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS": "+{bonus}%",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY": "без срока",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM": "• {description} (до {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER": "• {description}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES": " Истекает: {expires}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS": " Статус: {status}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED": "✅ Принято",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING": "⏳ Не принято",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT": " Осталось: {time}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED": "истекло",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION": " После активации: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION": " Всего действует: {duration}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER": "📊 Статистика предложений",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL": "Всего отправлено: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED": "Принято: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING": "Не принято: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE": "Активно сейчас: {count}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS": "🧪 Активные тестовые доступы:",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM": "• {squad} (до {expires})",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS": "📨 Активных предложений нет",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON": "📬 Отправить предложение",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST": "⬅️ К списку пользователей",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE": "👤 К профилю пользователя",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE": "📬 Отправка пользователю {name}",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
|
||||
"ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT": "Отправка не выполнена",
|
||||
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
|
||||
"ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов",
|
||||
"ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки",
|
||||
@@ -611,6 +780,37 @@
|
||||
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конфиденциальности",
|
||||
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "Политика конфиденциальности отображается в разделе «Инфо».",
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Отображение политики отключено или текст пустой.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Политика активна и доступна пользователям.",
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Политика включена, но текст пуст — пользователи не увидят ее.",
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Последнее обновление: {timestamp}",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Предпросмотр текста:</b>",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "Текст пока не задан.",
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Изменить текст",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 Просмотреть текущий текст",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Включить отображение",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Отключить отображение",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP": "ℹ️ Справка по HTML",
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Текущий текст (предпросмотр):",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Отправьте новый текст политики конфиденциальности. HTML-разметка разрешена.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Используйте /html_help для списка поддерживаемых тегов.",
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Редактирование политики конфиденциальности",
|
||||
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ Текст политики слишком длинный. Максимум 4000 символов.",
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ Ошибка HTML: {error}",
|
||||
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Политика конфиденциальности обновлена.",
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ Назад к настройкам политики",
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "Текст политики конфиденциальности ещё не задан.",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Текущий текст политики</b>",
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Пользователи увидят полную версию в меню.",
|
||||
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена",
|
||||
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена",
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
|
||||
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
|
||||
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
|
||||
"ADMIN_SETTINGS_BOT_CONFIG": "🧩 Конфигурация бота",
|
||||
@@ -834,6 +1034,7 @@
|
||||
"ADMIN_PRICING_BUTTON_PERIOD_OPTIONS": "🗓 Доступные периоды",
|
||||
"ADMIN_PRICING_BUTTON_PERIODS": "🗓 Периоды подписки",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC": "📦 Пакеты трафика",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Отображение пакетов",
|
||||
"ADMIN_PRICING_BUTTON_TRIAL": "🎁 Пробный период",
|
||||
"ADMIN_PRICING_CHOICE_ALREADY": "Это значение уже активно.",
|
||||
"ADMIN_PRICING_CHOICE_UPDATED": "Выбрано: {label}",
|
||||
@@ -850,6 +1051,9 @@
|
||||
"ADMIN_PRICING_PERIOD_DISABLED": "Период отключен.",
|
||||
"ADMIN_PRICING_PERIOD_ENABLED": "Период включен.",
|
||||
"ADMIN_PRICING_PERIOD_MIN": "Должен оставаться хотя бы один период.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Пакет включен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Пакет отключен.",
|
||||
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "Должен оставаться хотя бы один пакет.",
|
||||
"ADMIN_PRICING_SECTION_CORE_TITLE": "⚙️ Базовые лимиты",
|
||||
"ADMIN_PRICING_SECTION_CURRENT": "Текущие значения:",
|
||||
"ADMIN_PRICING_SECTION_EMPTY": "Нет доступных значений.",
|
||||
@@ -862,6 +1066,10 @@
|
||||
"ADMIN_PRICING_SECTION_PROMPT": "Выберите что изменить:",
|
||||
"ADMIN_PRICING_SECTION_SETTINGS_GENERIC": "⚙️ Настройки",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_TITLE": "📦 Пакеты трафика",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Отображение пакетов трафика",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Активные пакеты: {items}",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "Активных пакетов нет.",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Нажмите на пакет, чтобы включить или выключить его отображение.",
|
||||
"ADMIN_PRICING_SECTION_TRIAL_TITLE": "🎁 Пробный период",
|
||||
"ADMIN_PRICING_SETTING_CANCEL_HINT": "Чтобы вернуться без изменений, ответьте «Отмена».",
|
||||
"ADMIN_PRICING_SETTING_CURRENT": "Текущее значение",
|
||||
@@ -874,5 +1082,93 @@
|
||||
"ADMIN_PRICING_SETTING_PROMPT": "Отправьте новое значение или напишите «Отмена». Для очистки используйте none.",
|
||||
"ADMIN_PRICING_SETTING_SUCCESS": "Параметр {label} обновлен: {value}",
|
||||
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Важно"
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Важно",
|
||||
"MENU_PUBLIC_OFFER": "📄 Оферта",
|
||||
"PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
|
||||
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
|
||||
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
|
||||
"ADMIN_SETTINGS_FAQ": "❓ FAQ",
|
||||
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Превью текста:</b>",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_FAQ_HEADER": "❓ <b>FAQ</b>",
|
||||
"ADMIN_FAQ_DESCRIPTION": "FAQ отображается в разделе «Инфо».",
|
||||
"ADMIN_FAQ_LANGUAGE": "Язык: <code>{lang}</code>",
|
||||
"ADMIN_FAQ_PAGE_STATS": "Всего страниц: {total}",
|
||||
"ADMIN_FAQ_STATUS_DISABLED": "⚠️ Показ FAQ выключен.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED": "✅ FAQ включён. Активных страниц: {count}.",
|
||||
"ADMIN_FAQ_STATUS_ENABLED_EMPTY": "⚠️ FAQ включён, но нет активных страниц.",
|
||||
"ADMIN_FAQ_STATUS_EMPTY": "⚠️ FAQ ещё не настроен.",
|
||||
"ADMIN_FAQ_PAGES_EMPTY": "Страницы ещё не созданы.",
|
||||
"ADMIN_FAQ_PAGES_OVERVIEW": "<b>Список страниц:</b>\n{items}",
|
||||
"ADMIN_FAQ_ACTION_PROMPT": "Выберите действие:",
|
||||
"ADMIN_FAQ_ADD_PAGE_BUTTON": "➕ Добавить страницу",
|
||||
"ADMIN_FAQ_ENABLE_BUTTON": "✅ Включить показ",
|
||||
"ADMIN_FAQ_DISABLE_BUTTON": "🚫 Отключить показ",
|
||||
"ADMIN_FAQ_HTML_HELP": "ℹ️ HTML помощь",
|
||||
"ADMIN_FAQ_ENTER_TITLE": "Введите заголовок для новой страницы FAQ:",
|
||||
"ADMIN_FAQ_CANCEL_BUTTON": "⬅️ Отмена",
|
||||
"ADMIN_FAQ_TITLE_EMPTY": "❌ Заголовок не может быть пустым.",
|
||||
"ADMIN_FAQ_TITLE_TOO_LONG": "❌ Заголовок слишком длинный. Максимум 255 символов.",
|
||||
"ADMIN_FAQ_ENTER_CONTENT": "Отправьте содержимое страницы FAQ. Допускается HTML.",
|
||||
"ADMIN_FAQ_CONTENT_TOO_LONG": "❌ Текст слишком длинный. Максимум 6000 символов.",
|
||||
"ADMIN_FAQ_CONTENT_EMPTY": "❌ Текст не может быть пустым.",
|
||||
"ADMIN_FAQ_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_FAQ_PAGE_CREATED": "✅ Страница FAQ создана.",
|
||||
"ADMIN_FAQ_BACK_TO_LIST": "⬅️ К настройкам FAQ",
|
||||
"ADMIN_FAQ_ENABLED_ALERT": "✅ FAQ включён.",
|
||||
"ADMIN_FAQ_DISABLED_ALERT": "🚫 FAQ отключён.",
|
||||
"ADMIN_FAQ_PAGE_NOT_FOUND": "⚠️ Страница не найдена.",
|
||||
"ADMIN_FAQ_PAGE_HEADER": "📄 <b>Страница FAQ</b>",
|
||||
"ADMIN_FAQ_PAGE_STATUS_ACTIVE": "✅ Активна",
|
||||
"ADMIN_FAQ_PAGE_STATUS_INACTIVE": "🚫 Выключена",
|
||||
"ADMIN_FAQ_PAGE_UPDATED": "Обновлено: {timestamp}",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW_EMPTY": "Текст ещё не задан.",
|
||||
"ADMIN_FAQ_PAGE_PREVIEW": "<b>Превью:</b>\n{content}",
|
||||
"ADMIN_FAQ_PAGE_TITLE": "<b>Заголовок:</b> {title}",
|
||||
"ADMIN_FAQ_PAGE_STATUS": "Статус: {status}",
|
||||
"ADMIN_FAQ_EDIT_TITLE_BUTTON": "✏️ Изменить заголовок",
|
||||
"ADMIN_FAQ_EDIT_CONTENT_BUTTON": "📝 Изменить текст",
|
||||
"ADMIN_FAQ_PAGE_ENABLE_BUTTON": "✅ Включить страницу",
|
||||
"ADMIN_FAQ_PAGE_DISABLE_BUTTON": "🚫 Выключить страницу",
|
||||
"ADMIN_FAQ_PAGE_MOVE_UP": "⬆️ Выше",
|
||||
"ADMIN_FAQ_PAGE_MOVE_DOWN": "⬇️ Ниже",
|
||||
"ADMIN_FAQ_PAGE_DELETE_BUTTON": "🗑️ Удалить",
|
||||
"ADMIN_FAQ_TITLE_UPDATED": "✅ Заголовок обновлён.",
|
||||
"ADMIN_FAQ_UNEXPECTED_STATE": "⚠️ Состояние сброшено.",
|
||||
"ADMIN_FAQ_CONTENT_UPDATED": "✅ Текст страницы обновлён.",
|
||||
"ADMIN_FAQ_PAGE_ENABLED_ALERT": "✅ Страница включена.",
|
||||
"ADMIN_FAQ_PAGE_DISABLED_ALERT": "🚫 Страница выключена.",
|
||||
"ADMIN_FAQ_PAGE_DELETED": "🗑️ Страница удалена.",
|
||||
"ADMIN_FAQ_PAGE_REORDERED": "✅ Порядок обновлён.",
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
|
||||
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
|
||||
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Текущий текст оферты</b>",
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
|
||||
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено."
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.services.backup_service import backup_service
|
||||
from app.services.reporting_service import reporting_service
|
||||
from app.localization.loader import ensure_locale_templates
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.external_admin_service import ensure_external_admin_token
|
||||
from app.services.broadcast_service import broadcast_service
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
|
||||
@@ -186,6 +187,25 @@ async def main():
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
async with timeline.stage(
|
||||
"Внешняя админка",
|
||||
"🛡️",
|
||||
success_message="Токен внешней админки готов",
|
||||
) as stage:
|
||||
try:
|
||||
bot_user = await bot.get_me()
|
||||
token = await ensure_external_admin_token(
|
||||
bot_user.username,
|
||||
bot_user.id,
|
||||
)
|
||||
if token:
|
||||
stage.log("Токен синхронизирован")
|
||||
else:
|
||||
stage.warning("Не удалось получить токен внешней админки")
|
||||
except Exception as error: # pragma: no cover - защитный блок
|
||||
stage.warning(f"Ошибка подготовки внешней админки: {error}")
|
||||
logger.error("❌ Ошибка подготовки внешней админки: %s", error)
|
||||
|
||||
webhook_needed = (
|
||||
settings.TRIBUTE_ENABLED
|
||||
or settings.is_cryptobot_enabled()
|
||||
|
||||
+3953
-99
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user