Compare commits
311 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f2fc7581a3 | |||
| e96010d24d | |||
| 35fc39b9f2 | |||
| 7129625ab5 | |||
| e480356447 | |||
| 46ea2de300 | |||
| 2152ded3c4 | |||
| da0e8a2e3e | |||
| da540e8680 | |||
| a12ad52c7e | |||
| 38b5fa297b | |||
| d50475974c | |||
| ada891f2d9 | |||
| e7467e5434 | |||
| fa4a692574 | |||
| 9c60ef2c33 | |||
| 634633d12e | |||
| b3fd0c50ca | |||
| acb267668e | |||
| 344cffef60 | |||
| ef652d4518 | |||
| a66f9bbf28 | |||
| 3e5fbcf2bd | |||
| ba2de9e150 | |||
| b638e04629 | |||
| 45a5f012c2 | |||
| 1a66094800 | |||
| f7afe481bb | |||
| f04bf43962 | |||
| 3964ed1f3c | |||
| 98fa8eef5d | |||
| c7b47eef4d | |||
| 9270bb3bf5 | |||
| ad2f6f6f22 | |||
| 62749f9ffe | |||
| e623f9df3a | |||
| 2b79de7d82 | |||
| cdcc3b4c4a | |||
| e757569628 | |||
| 4f88cdf7db | |||
| 406c13aea2 | |||
| c95b1e4fad | |||
| 43c480fa33 | |||
| cb25bbb2ed | |||
| 2602a05fb2 | |||
| c31da6c2ae | |||
| 17d51ab286 | |||
| e576cb97ec | |||
| 217fed9c9c | |||
| 030f4efbe1 | |||
| 33b3828abc | |||
| f2876fd02c | |||
| ee9ba71f3c | |||
| 6a5d5d960f | |||
| 910409cdfb | |||
| d78b36b60e | |||
| ca2f83db88 | |||
| bf591f55ea | |||
| 99aa09349e | |||
| a391b635ed | |||
| 4f1ff34dce | |||
| 9a23a305b5 | |||
| 539e7e9066 | |||
| 6b1173a1b5 | |||
| 23f8e158ad | |||
| 578061be8c | |||
| 84816b84e7 | |||
| 0ad2f5bb8a | |||
| e415b4b2ee | |||
| 7d6ecaaa1b | |||
| 588461c53a | |||
| 25c23bd653 | |||
| 1a033de259 | |||
| 850f8d2b2e | |||
| 39374095eb | |||
| f4fe8913fa | |||
| 9a5203770b | |||
| 64b0f5161c | |||
| 8e12609746 | |||
| 703d09c556 | |||
| 7b238a32b4 | |||
| c72fa990fa | |||
| f8fc929cbf | |||
| 744aaeb5d6 | |||
| 515088ff45 | |||
| 96b64df989 | |||
| f51c22c67e | |||
| 010c412268 | |||
| 7d204684e1 | |||
| 08f47694ee | |||
| e61977a368 | |||
| 72ae83e6ef | |||
| 80aa0c2443 | |||
| 9e0eb3fd4a | |||
| 6fe4bad240 | |||
| 859089e91d | |||
| c4fa25321e | |||
| 96a6f26b1e | |||
| 3f0d125207 | |||
| cb5821fffc | |||
| f79420a9b0 | |||
| a7b4dc985f | |||
| 38562744ab | |||
| 3d413ced59 | |||
| 0002ce0a9a | |||
| 140d3580e9 | |||
| cf5cb406d0 | |||
| 8c3f3d56c8 | |||
| d33831dfe4 | |||
| b68f1256ca | |||
| 3d3b019e60 | |||
| f1a801d693 | |||
| de33e15ae0 | |||
| abd87c2a48 | |||
| f691e91560 | |||
| 7dafb0c29f | |||
| 5037d92e36 | |||
| d765e08017 | |||
| 4958613ad9 | |||
| 489667c847 | |||
| ccedfad51f | |||
| 841ac5cdb3 | |||
| a8b6202fe9 | |||
| 4708c67dcc | |||
| f705b508a1 | |||
| 6030fb08ec | |||
| a5b152e73a | |||
| 087a57fc11 | |||
| 95e44f99a2 | |||
| b8bb2acd64 | |||
| eb3e806e0f | |||
| 33911aca93 | |||
| 63c6e64cbe | |||
| 358c8aed3b | |||
| 4ac994454b | |||
| 5d6f080663 | |||
| 33f0e3b9ba | |||
| ef809de9a2 | |||
| 628c53513c | |||
| 876362cd80 | |||
| 1a9efd65a1 | |||
| 121918c33a | |||
| b46c3bd142 | |||
| 566b563626 | |||
| 397af1cc86 | |||
| bdfa2a3039 | |||
| 38001410b8 | |||
| c2693fc46a | |||
| 9db84af7a9 | |||
| 0172138084 | |||
| 0e913ec78b | |||
| 6a5d0181b0 | |||
| 7684ed382e | |||
| 0d47acf044 | |||
| 2d09b10f36 | |||
| 77c99ad33e | |||
| a41531944c | |||
| 9b2d9406d3 | |||
| 1776a5b424 | |||
| 419b921487 | |||
| c1f7404912 | |||
| 1c07f40d43 | |||
| d2e6a42eb8 | |||
| 5a7960bb14 | |||
| 27d48f68b4 | |||
| 9465c6cba3 | |||
| 3088be1dde | |||
| 845a8175fa | |||
| af27fb570c | |||
| 181bb137fb | |||
| 220646c7ac | |||
| 4513deb2a1 | |||
| 02a9ac9be5 | |||
| 23f53dc5df | |||
| b4d857eb88 | |||
| 51ba5b980c | |||
| 9fb6d7ecf6 | |||
| d297e9b6d1 |
@@ -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.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.4-$(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.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.4-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.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.4-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.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.4-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.4.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.4-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.4.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.4.4-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!install_bot.sh
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.4.2"
|
||||
ARG VERSION="v2.4.4"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -71,7 +71,53 @@ eGames Cookies | Cookies в формате key:value | Для панелей eGa
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 🐳 Docker запуск
|
||||
### 🧙♂️ Автоустановка через `install_bot.sh`
|
||||
|
||||
Скрипт-установщик берёт на себя подготовку окружения, настройку конфигурации и дальнейшее обслуживание бота. Он работает поверх Docker Compose и требует заранее установить:
|
||||
|
||||
- **Docker Engine** и **Docker Compose plugin** (2.20+);
|
||||
- **Git** и **Bash** (по умолчанию есть в большинстве Linux дистрибутивов);
|
||||
- `openssl` (используется для генерации токенов, но не обязателен — при отсутствии скрипт использует `urandom`).
|
||||
|
||||
```bash
|
||||
# 1. Скачай репозиторий
|
||||
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
|
||||
# 2. Создай необходимые директории
|
||||
mkdir -p ./logs ./data ./data/backups ./data/referral_qr
|
||||
chmod -R 755 ./logs ./data
|
||||
sudo chown -R 1000:1000 ./logs ./data
|
||||
|
||||
# 3. Запусти мастер установки
|
||||
chmod +x install_bot.sh # один раз, если файл не исполняемый
|
||||
./install_bot.sh
|
||||
```
|
||||
|
||||
На первом запуске мастер:
|
||||
|
||||
1. Спросит путь установки и сохранит его в `./.bot_install_state` — можно оставлять путь по умолчанию (текущая директория).
|
||||
2. Поможет собрать `.env`: запросит обязательные токены (бот, Remnawave, админы), при необходимости сгенерирует Web API и PostgreSQL пароли, предложит авторизацию Basic Auth или eGames secret.
|
||||
3. Подготовит структуру каталогов (`logs`, `data`, `backups` и т. д.) и проверит, что Docker готов к запуску.
|
||||
4. Создаст (или обновит) `docker-compose.yml`, настроит внешнюю сеть `bot_network`, чтобы дальнейшие сервисы (например, Caddy) могли подключаться.
|
||||
5. Запустит контейнеры бота, PostgreSQL и Redis и выведет их статус.
|
||||
|
||||
После установки повторный запуск `./install_bot.sh` открывает **интерактивное меню управления**:
|
||||
|
||||
- 📊 Мониторинг состояния контейнеров и ресурсов.
|
||||
- ⚙️ Управление сервисами (запуск/остановка/пересборка).
|
||||
- 📋 Просмотр и поиск по логам.
|
||||
- 🔄 Обновление проекта из Git с автоматическим бэкапом.
|
||||
- 💾 Создание и 📦 восстановление резервных копий (включая базу данных).
|
||||
- 🧹 Очистка логов, бэкапов и образов.
|
||||
- 🌐 Помощник настройки обратного прокси Caddy (webhook + miniapp, обновление `docker-compose`, перезагрузка).
|
||||
- ⚙️ Конфигуратор `.env` (редактирование, пересоздание, маскировка секретов).
|
||||
|
||||
> 💡 Скрипт можно запускать сколько угодно раз — он хранит путь установки и понимает, когда конфигурация уже создана. Меню работает и по SSH (достаточно TTY), а для скриптов можно передать путь установки через stdin.
|
||||
|
||||
### 🐳 Ручной Docker запуск
|
||||
|
||||
Если не хочется пользоваться мастером, можно настроить всё вручную:
|
||||
|
||||
```bash
|
||||
# 1. Скачай репозиторий
|
||||
@@ -91,11 +137,269 @@ sudo chown -R 1000:1000 ./logs ./data
|
||||
docker compose up -d
|
||||
|
||||
# 5. Проверь статус
|
||||
docker compose logs
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Настройка обратного прокси и доменов
|
||||
|
||||
> Этот раздел описывает полноценную ручную настройку обратного прокси для **двух разных доменов**: отдельный домен для вебхуков
|
||||
> (`hooks.example.com`) и отдельный домен для мини-приложения (`miniapp.example.com`). Оба прокси-сервера (Caddy или nginx) дол
|
||||
> жны работать в одной Docker-сети с ботом, чтобы обращаться к сервису по внутреннему имени `remnawave_bot` без проброса порто
|
||||
> в наружу.
|
||||
|
||||
### 1. Планирование доменов и переменных окружения
|
||||
|
||||
1. Добавьте в DNS по **A/AAAA-записи** для обоих доменов на IP сервера, где запущен бот.
|
||||
2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол).
|
||||
3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами:
|
||||
```env
|
||||
WEBHOOK_URL=https://hooks.example.com
|
||||
WEB_API_ENABLED=true
|
||||
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
|
||||
MINIAPP_CUSTOM_URL=https://miniapp.example.com
|
||||
```
|
||||
При необходимости добавьте пути для дополнительных провайдеров (`TRIBUTE_WEBHOOK_PATH`, `YOOKASSA_WEBHOOK_PATH` и т. д.) —
|
||||
далее в конфигурациях мы их пробрасываем на соответствующие порты контейнера бота.
|
||||
|
||||
### 2. Общая Docker-сеть для бота и прокси
|
||||
|
||||
`docker-compose.yml` бота создаёт сеть `bot_network`. Чтобы внешний прокси видел сервис `remnawave_bot`, нужно:
|
||||
|
||||
```bash
|
||||
# Если бот уже запущен через docker compose up -d
|
||||
docker compose ps
|
||||
|
||||
# Убедиться, что сеть существует
|
||||
docker network ls | grep bot_network || docker network create bot_network
|
||||
|
||||
# Подключить прокси (если контейнер уже запущен отдельно)
|
||||
docker network connect bot_network <proxy_container_name>
|
||||
```
|
||||
|
||||
Если прокси запускается через **собственный docker-compose**, в файле нужно объявить ту же сеть как внешнюю:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
|
||||
Именно так поступает мастер `install_bot.sh`: при первичной установке он создаёт `bot_network` и при повторном запуске в меню №
|
||||
8 («🌐 Настройка обратного прокси (Caddy)») может автоматически подготовить `docker-compose` и `Caddyfile`, обновить сеть и пер
|
||||
езапустить сервис.
|
||||
|
||||
### 3. Ручная установка Caddy в Docker
|
||||
|
||||
1. Создайте каталог для конфигурации:
|
||||
```bash
|
||||
mkdir -p ~/caddy
|
||||
cd ~/caddy
|
||||
```
|
||||
2. Сохраните docker-compose-файл `docker-compose.caddy.yml`:
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: remnawave_caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
# Укажите путь к каталогу miniapp из репозитория бота
|
||||
- /root/remnawave-bedolaga-telegram-bot/miniapp:/miniapp:ro
|
||||
- /root/remnawave-bedolaga-telegram-bot/miniapp/redirect:/miniapp/redirect:ro
|
||||
networks:
|
||||
- bot_network
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
3. Создайте `Caddyfile` с двумя виртуальными хостами:
|
||||
```caddy
|
||||
webhook.domain.com {
|
||||
handle /tribute-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /cryptobot-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /mulenpay-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /pal24-webhook* {
|
||||
reverse_proxy localhost:8084
|
||||
}
|
||||
|
||||
handle /yookassa-webhook* {
|
||||
reverse_proxy localhost:8082
|
||||
}
|
||||
|
||||
handle /health {
|
||||
reverse_proxy localhost:8081/health
|
||||
}
|
||||
}
|
||||
|
||||
miniapp.domain.com {
|
||||
encode gzip zstd
|
||||
root * /var/www/remnawave-miniapp
|
||||
file_server
|
||||
|
||||
@config path /app-config.json
|
||||
header @config Access-Control-Allow-Origin "*"
|
||||
|
||||
# Redirect for /miniapp/redirect/index.html
|
||||
@redirect path /miniapp/redirect/index.html
|
||||
redir @redirect /miniapp/redirect/index.html permanent
|
||||
|
||||
reverse_proxy /miniapp/* 127.0.0.1:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
```
|
||||
- Каталог `miniapp` из репозитория должен быть смонтирован в контейнер Caddy по путям `/miniapp` и `/miniapp/redirect`,
|
||||
чтобы статические файлы мини-приложения и страницы редиректа отдавались без дополнительной сборки. Замените
|
||||
`/root/remnawave-bedolaga-telegram-bot` в примере на реальный путь до установленного репозитория.
|
||||
- Caddy автоматически выпустит TLS-сертификаты через ACME, если DNS настроен правильно.
|
||||
4. Запустите прокси и убедитесь, что он присоединился к сети бота:
|
||||
```bash
|
||||
docker compose -f docker-compose.caddy.yml up -d
|
||||
docker network inspect bot_network | grep remnawave_caddy
|
||||
```
|
||||
5. Проверьте журналы Caddy и бота, выполните тестовый запрос `curl https://hooks.example.com/webhook` (должен отвечать 405/200
|
||||
в зависимости от webhook).
|
||||
|
||||
### 4. Ручная настройка nginx в Docker
|
||||
|
||||
1. Создайте каталог `/opt/nginx-remnawave` и поместите туда `docker-compose.nginx.yml`:
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
container_name: remnawave_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certs:/etc/ssl/private:ro
|
||||
- ./miniapp:/var/www/remnawave-miniapp:ro
|
||||
networks:
|
||||
- bot_network
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
external: true
|
||||
```
|
||||
Сертификаты можно выпускать вручную (certbot) и складывать в `./certs` либо использовать внешний контейнер `certbot`.
|
||||
2. Пример `nginx.conf` с двумя серверными блоками:
|
||||
```nginx
|
||||
events {}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
upstream remnawave_bot_hooks {
|
||||
server remnawave_bot:8081;
|
||||
}
|
||||
|
||||
upstream remnawave_bot_yookassa {
|
||||
server remnawave_bot:8082;
|
||||
}
|
||||
|
||||
upstream remnawave_bot_api {
|
||||
server remnawave_bot:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name hooks.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
|
||||
|
||||
location = /webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /tribute-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /cryptobot-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /mulenpay-webhook { proxy_pass http://remnawave_bot_hooks; }
|
||||
location /pal24-webhook { proxy_pass http://remnawave_bot:8084; }
|
||||
location /yookassa-webhook { proxy_pass http://remnawave_bot_yookassa; }
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name miniapp.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
|
||||
|
||||
root /var/www/remnawave-miniapp;
|
||||
index index.html;
|
||||
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
location /miniapp/ {
|
||||
proxy_pass http://remnawave_bot_api/miniapp/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-API-Key "КЛЮЧ-WEBAPI";
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
3. Запустите прокси и убедитесь, что он в сети `bot_network`:
|
||||
```bash
|
||||
docker compose -f docker-compose.nginx.yml up -d
|
||||
docker network inspect bot_network | grep remnawave_nginx
|
||||
```
|
||||
4. После запуска перепроверьте, что `curl -I https://hooks.example.com/webhook` возвращает HTTP-ответ 405/200, а `curl -H "X-AP
|
||||
I-Key: ..." https://miniapp.example.com/miniapp/health` отвечает 200.
|
||||
|
||||
### 5. Чек-лист проверки
|
||||
|
||||
- [ ] DNS-записи указывают на сервер, сертификаты валидны.
|
||||
- [ ] Контейнер прокси присоединён к `bot_network` и видит сервис `remnawave_bot`.
|
||||
- [ ] `WEBHOOK_URL` и `MINIAPP_CUSTOM_URL` в `.env` указывают на HTTPS-домены.
|
||||
- [ ] В логах бота нет ошибок `connection refused` или `invalid host header` при обращении из прокси.
|
||||
- [ ] Триггеры оплаты (Tribute, YooKassa и др.) успешно получают HTTP 200 при тестовых запросах на свои вебхуки.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Конфигурация
|
||||
|
||||
### 🔧 Основные параметры
|
||||
|
||||
@@ -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,
|
||||
@@ -41,6 +42,7 @@ from app.handlers.admin import (
|
||||
tickets as admin_tickets,
|
||||
reports as admin_reports,
|
||||
bot_configuration as admin_bot_configuration,
|
||||
pricing as admin_pricing,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -136,6 +138,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)
|
||||
@@ -145,6 +148,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_tickets.register_handlers(dp)
|
||||
admin_reports.register_handlers(dp)
|
||||
admin_bot_configuration.register_handlers(dp)
|
||||
admin_pricing.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+27
-11
@@ -38,7 +38,7 @@ class Settings(BaseSettings):
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
|
||||
DATABASE_URL: str = ""
|
||||
DATABASE_URL: Optional[str] = None
|
||||
|
||||
POSTGRES_HOST: str = "postgres"
|
||||
POSTGRES_PORT: int = 5432
|
||||
@@ -53,8 +53,8 @@ class Settings(BaseSettings):
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
REMNAWAVE_API_URL: str = ""
|
||||
REMNAWAVE_API_KEY: str = ""
|
||||
REMNAWAVE_API_URL: Optional[str] = None
|
||||
REMNAWAVE_API_KEY: Optional[str] = None
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
|
||||
REMNAWAVE_USERNAME: Optional[str] = None
|
||||
@@ -69,7 +69,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: str = ""
|
||||
TRIAL_SQUAD_UUID: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
@@ -1061,15 +1061,31 @@ class Settings(BaseSettings):
|
||||
|
||||
settings = Settings()
|
||||
|
||||
PERIOD_PRICES = {
|
||||
14: settings.PRICE_14_DAYS,
|
||||
30: settings.PRICE_30_DAYS,
|
||||
60: settings.PRICE_60_DAYS,
|
||||
90: settings.PRICE_90_DAYS,
|
||||
180: settings.PRICE_180_DAYS,
|
||||
360: settings.PRICE_360_DAYS,
|
||||
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
|
||||
14: "PRICE_14_DAYS",
|
||||
30: "PRICE_30_DAYS",
|
||||
60: "PRICE_60_DAYS",
|
||||
90: "PRICE_90_DAYS",
|
||||
180: "PRICE_180_DAYS",
|
||||
360: "PRICE_360_DAYS",
|
||||
}
|
||||
|
||||
|
||||
def refresh_period_prices() -> None:
|
||||
"""Rebuild cached period price mapping using the latest settings."""
|
||||
|
||||
PERIOD_PRICES.clear()
|
||||
PERIOD_PRICES.update(
|
||||
{
|
||||
days: getattr(settings, field_name, 0)
|
||||
for days, field_name in _PERIOD_PRICE_FIELDS.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
PERIOD_PRICES: Dict[int, int] = {}
|
||||
refresh_period_prices()
|
||||
|
||||
def get_traffic_prices() -> Dict[int, int]:
|
||||
packages = settings.get_traffic_packages()
|
||||
return {package["gb"]: package["price"] for package in packages}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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 +22,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 +45,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 +56,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)
|
||||
|
||||
@@ -61,11 +73,42 @@ async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountO
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
|
||||
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 +125,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,72 @@
|
||||
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,
|
||||
) -> Tuple[List[PromoOfferLog], int]:
|
||||
stmt = (
|
||||
select(PromoOfferLog)
|
||||
.options(
|
||||
selectinload(PromoOfferLog.user),
|
||||
selectinload(PromoOfferLog.offer),
|
||||
)
|
||||
.order_by(PromoOfferLog.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
|
||||
count_stmt = select(func.count(PromoOfferLog.id))
|
||||
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
|
||||
+302
-38
@@ -3,14 +3,16 @@ import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict
|
||||
from sqlalchemy import select, and_, or_, func, case, nullslast
|
||||
from sqlalchemy import select, and_, or_, func, case, nullslast, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
PaymentMethod,
|
||||
@@ -18,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__)
|
||||
@@ -85,6 +89,37 @@ async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
async def _sync_users_sequence(db: AsyncSession) -> None:
|
||||
"""Ensure the users.id sequence matches the current max ID."""
|
||||
await db.execute(
|
||||
text(
|
||||
"SELECT setval('users_id_seq', "
|
||||
"COALESCE((SELECT MAX(id) FROM users), 0) + 1, false)"
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logger.warning(
|
||||
"🔄 Последовательность users_id_seq была синхронизирована с текущим максимумом id"
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
|
||||
default_group = await get_default_promo_group(db)
|
||||
if default_group:
|
||||
return default_group
|
||||
|
||||
default_group = PromoGroup(
|
||||
name="Базовый юзер",
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
return default_group
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
@@ -99,46 +134,61 @@ async def create_user(
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
default_group = PromoGroup(
|
||||
name="Базовый юзер",
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
attempts = 3
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
|
||||
safe_first = sanitize_telegram_name(first_name)
|
||||
safe_last = sanitize_telegram_name(last_name)
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
|
||||
promo_group_id = default_group.id
|
||||
db.add(user)
|
||||
|
||||
safe_first = sanitize_telegram_name(first_name)
|
||||
safe_last = sanitize_telegram_name(last_name)
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if default_group:
|
||||
user.promo_group = default_group
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
user.promo_group = default_group
|
||||
logger.info(
|
||||
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
|
||||
)
|
||||
return user
|
||||
|
||||
return user
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
|
||||
if (
|
||||
isinstance(getattr(exc, "orig", None), Exception)
|
||||
and "users_pkey" in str(exc.orig)
|
||||
and attempt < attempts
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. "
|
||||
"Выполняем повторную синхронизацию (попытка %s/%s)",
|
||||
telegram_id,
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
await _sync_users_sequence(db)
|
||||
continue
|
||||
|
||||
raise
|
||||
|
||||
raise RuntimeError("Не удалось создать пользователя после синхронизации последовательности")
|
||||
|
||||
|
||||
async def update_user(
|
||||
@@ -225,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})")
|
||||
@@ -232,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)
|
||||
|
||||
@@ -258,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
|
||||
|
||||
@@ -267,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,
|
||||
@@ -468,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)
|
||||
|
||||
|
||||
@@ -386,9 +386,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 +457,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:
|
||||
@@ -811,11 +816,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"
|
||||
|
||||
@@ -14,6 +14,96 @@ logger = logging.getLogger(__name__)
|
||||
async def get_database_type():
|
||||
return engine.dialect.name
|
||||
|
||||
|
||||
async def sync_postgres_sequences() -> bool:
|
||||
"""Ensure PostgreSQL sequences match the current max values after restores."""
|
||||
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type != "postgresql":
|
||||
logger.debug("Пропускаем синхронизацию последовательностей: тип БД %s", db_type)
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
cols.table_schema,
|
||||
cols.table_name,
|
||||
cols.column_name,
|
||||
pg_get_serial_sequence(
|
||||
format('%I.%I', cols.table_schema, cols.table_name),
|
||||
cols.column_name
|
||||
) AS sequence_path
|
||||
FROM information_schema.columns AS cols
|
||||
WHERE cols.column_default LIKE 'nextval(%'
|
||||
AND cols.table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
sequences = result.fetchall()
|
||||
|
||||
if not sequences:
|
||||
logger.info("ℹ️ Не найдено последовательностей PostgreSQL для синхронизации")
|
||||
return True
|
||||
|
||||
for table_schema, table_name, column_name, sequence_path in sequences:
|
||||
if not sequence_path:
|
||||
continue
|
||||
|
||||
max_result = await conn.execute(
|
||||
text(
|
||||
f'SELECT COALESCE(MAX("{column_name}"), 0) '
|
||||
f'FROM "{table_schema}"."{table_name}"'
|
||||
)
|
||||
)
|
||||
max_value = max_result.scalar() or 0
|
||||
|
||||
parts = sequence_path.split('.')
|
||||
if len(parts) == 2:
|
||||
seq_schema, seq_name = parts
|
||||
else:
|
||||
seq_schema, seq_name = 'public', parts[-1]
|
||||
|
||||
seq_schema = seq_schema.strip('"')
|
||||
seq_name = seq_name.strip('"')
|
||||
current_result = await conn.execute(
|
||||
text(
|
||||
f'SELECT last_value, is_called FROM "{seq_schema}"."{seq_name}"'
|
||||
)
|
||||
)
|
||||
current_row = current_result.fetchone()
|
||||
|
||||
if current_row:
|
||||
current_last, is_called = current_row
|
||||
current_next = current_last + 1 if is_called else current_last
|
||||
if current_next > max_value:
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT setval(:sequence_name, :new_value, TRUE)
|
||||
"""
|
||||
),
|
||||
{"sequence_name": sequence_path, "new_value": max_value},
|
||||
)
|
||||
logger.info(
|
||||
"🔄 Последовательность %s синхронизирована: MAX=%s, следующий ID=%s",
|
||||
sequence_path,
|
||||
max_value,
|
||||
max_value + 1,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("❌ Ошибка синхронизации последовательностей PostgreSQL: %s", error)
|
||||
return False
|
||||
|
||||
async def check_table_exists(table_name: str) -> bool:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -638,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,
|
||||
@@ -661,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
|
||||
)
|
||||
@@ -682,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,
|
||||
@@ -703,6 +799,422 @@ 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_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:
|
||||
@@ -2043,11 +2555,19 @@ async def run_universal_migration():
|
||||
try:
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Тип базы данных: {db_type}")
|
||||
|
||||
|
||||
if db_type == 'postgresql':
|
||||
logger.info("=== СИНХРОНИЗАЦИЯ ПОСЛЕДОВАТЕЛЬНОСТЕЙ PostgreSQL ===")
|
||||
sequences_synced = await sync_postgres_sequences()
|
||||
if sequences_synced:
|
||||
logger.info("✅ Последовательности PostgreSQL синхронизированы")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось синхронизировать последовательности PostgreSQL")
|
||||
|
||||
referral_migration_success = await add_referral_system_columns()
|
||||
if not referral_migration_success:
|
||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===")
|
||||
system_settings_ready = await create_system_settings_table()
|
||||
if system_settings_ready:
|
||||
@@ -2103,6 +2623,57 @@ 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")
|
||||
|
||||
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:
|
||||
@@ -2303,7 +2874,17 @@ async def check_migration_status():
|
||||
"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')
|
||||
@@ -2315,6 +2896,14 @@ async def check_migration_status():
|
||||
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
||||
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
|
||||
|
||||
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')
|
||||
status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts')
|
||||
@@ -2322,6 +2911,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 = (
|
||||
@@ -2361,7 +2953,17 @@ async def check_migration_status():
|
||||
"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
+36
-12
@@ -112,14 +112,18 @@ class WebhookServer:
|
||||
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
|
||||
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Пустой Mulen Pay webhook")
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
# Временно отключаем проверку подписи для отладки
|
||||
# TODO: Включить обратно после настройки MulenPay
|
||||
if not self._verify_mulenpay_signature(request, raw_body):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
|
||||
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
@@ -129,17 +133,23 @@ class WebhookServer:
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
async for db in get_db():
|
||||
# Получаем соединение с БД
|
||||
db_generator = get_db()
|
||||
db = await db_generator.__anext__()
|
||||
|
||||
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:
|
||||
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
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
|
||||
@@ -160,6 +170,12 @@ class WebhookServer:
|
||||
logger.error("Mulen Pay secret key is not configured")
|
||||
return False
|
||||
|
||||
# Логируем все заголовки для отладки
|
||||
logger.info("MulenPay webhook headers for signature verification:")
|
||||
for header_name, header_value in request.headers.items():
|
||||
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
|
||||
logger.info(f" {header_name}: {header_value}")
|
||||
|
||||
signature = WebhookServer._extract_mulenpay_header(
|
||||
request,
|
||||
(
|
||||
@@ -171,6 +187,14 @@ class WebhookServer:
|
||||
'X-MULENPAY-WEBHOOK-SIGNATURE',
|
||||
'X-Signature',
|
||||
'Signature',
|
||||
'X-MulenPay-Sign',
|
||||
'X-Mulenpay-Sign',
|
||||
'X-MULENPAY-SIGN',
|
||||
'MulenPay-Signature',
|
||||
'Mulenpay-Signature',
|
||||
'MULENPAY-SIGNATURE',
|
||||
'signature',
|
||||
'sign',
|
||||
)
|
||||
)
|
||||
if signature:
|
||||
@@ -228,7 +252,7 @@ class WebhookServer:
|
||||
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
logger.info(
|
||||
"Mulen Pay webhook headers received: %s",
|
||||
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
|
||||
)
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -171,7 +171,7 @@ async def show_servers_menu(
|
||||
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_subscriptions_keyboard
|
||||
@@ -87,10 +86,6 @@ async def show_subscriptions_menu(
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
|
||||
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
|
||||
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
@@ -277,56 +272,6 @@ async def show_subscriptions_stats(
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_pricing_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = f"""
|
||||
⚙️ <b>Настройки цен</b>
|
||||
|
||||
<b>Периоды подписки:</b>
|
||||
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
|
||||
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
|
||||
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
|
||||
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
|
||||
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
|
||||
|
||||
<b>Трафик-пакеты:</b>
|
||||
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
|
||||
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
|
||||
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
|
||||
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
|
||||
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
|
||||
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
|
||||
|
||||
<b>Дополнительно:</b>
|
||||
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
|
||||
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
|
||||
# ],
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
|
||||
# ],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_countries_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -487,7 +432,6 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
|
||||
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
|
||||
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
|
||||
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
|
||||
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
|
||||
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
|
||||
|
||||
|
||||
+34
-1
@@ -17,6 +17,10 @@ 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.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -300,9 +304,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:
|
||||
|
||||
@@ -32,6 +32,10 @@ from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
|
||||
|
||||
@@ -1279,6 +1283,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:
|
||||
|
||||
+341
-51
@@ -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,6 +72,10 @@ 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__)
|
||||
|
||||
@@ -110,6 +119,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 +277,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 +374,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 +482,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 +826,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,
|
||||
@@ -1032,13 +1096,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 +2439,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(
|
||||
@@ -2418,7 +2483,8 @@ async def handle_extend_subscription(
|
||||
total_traffic_price = (traffic_price_per_month - traffic_discount_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] = promo_component["discounted"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка расчета цены для периода {days}: {e}")
|
||||
@@ -2455,6 +2521,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 +2741,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 +2793,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 +2851,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 +2895,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 +2990,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 +3556,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 +3595,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 +3627,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 +3636,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 +3668,7 @@ async def confirm_purchase(
|
||||
base_price,
|
||||
discounted_monthly_additions,
|
||||
months_in_period,
|
||||
final_price,
|
||||
validation_total_price,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
@@ -3611,6 +3717,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 +3758,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 +3925,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 +3974,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 +4050,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)
|
||||
)
|
||||
|
||||
@@ -4314,8 +4445,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 +4460,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 +4485,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 +5186,184 @@ 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,
|
||||
},
|
||||
)
|
||||
|
||||
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_message = texts.get(
|
||||
"DISCOUNT_CLAIM_SUCCESS",
|
||||
"🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
|
||||
).format(
|
||||
percent=offer.discount_percent,
|
||||
amount=settings.format_price(bonus_amount),
|
||||
)
|
||||
"🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
|
||||
).format(percent=discount_percent)
|
||||
|
||||
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 +6409,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"
|
||||
|
||||
+46
-10
@@ -13,12 +13,46 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_USERS_SUBSCRIPTIONS", "👥 Юзеры/Подписки"), callback_data="admin_submenu_users")],
|
||||
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"), callback_data="admin_submenu_promo")],
|
||||
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SUPPORT", "🛟 Поддержка"), callback_data="admin_submenu_support")],
|
||||
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_MESSAGES", "📨 Сообщения"), callback_data="admin_submenu_communications")],
|
||||
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SETTINGS", "⚙️ Настройки"), callback_data="admin_submenu_settings")],
|
||||
[InlineKeyboardButton(text=_t(texts, "ADMIN_MAIN_SYSTEM", "🛠️ Система"), callback_data="admin_submenu_system")],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_USERS_SUBSCRIPTIONS", "👥 Юзеры/Подписки"),
|
||||
callback_data="admin_submenu_users",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_SERVERS", "🌐 Серверы"),
|
||||
callback_data="admin_servers",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_PRICING", "💰 Цены"),
|
||||
callback_data="admin_pricing",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"),
|
||||
callback_data="admin_submenu_promo",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_SUPPORT", "🛟 Поддержка"),
|
||||
callback_data="admin_submenu_support",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_MESSAGES", "📨 Сообщения"),
|
||||
callback_data="admin_submenu_communications",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_SETTINGS", "⚙️ Настройки"),
|
||||
callback_data="admin_submenu_settings",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_MAIN_SYSTEM", "🛠️ Система"),
|
||||
callback_data="admin_submenu_system",
|
||||
),
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
@@ -67,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", "👋 Приветственный текст"),
|
||||
@@ -299,10 +339,6 @@ def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SUBSCRIPTIONS_PRICING", "⚙️ Настройки цен"),
|
||||
callback_data="admin_subs_pricing"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_SUBSCRIPTIONS_COUNTRIES", "🌍 Управление странами"),
|
||||
callback_data="admin_subs_countries"
|
||||
|
||||
@@ -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})",
|
||||
@@ -522,6 +525,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 +561,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 +611,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.",
|
||||
|
||||
@@ -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 пользователя не найден",
|
||||
@@ -524,6 +527,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 +561,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 +611,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.",
|
||||
|
||||
@@ -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,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()
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -6,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
@@ -75,16 +76,80 @@ 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()
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
base_url = (auth_params.get("base_url") or "").strip()
|
||||
api_key = (auth_params.get("api_key") or "").strip()
|
||||
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
|
||||
|
||||
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._last_config_signature = config_signature
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return self._config_error is None
|
||||
|
||||
@property
|
||||
def configuration_error(self) -> Optional[str]:
|
||||
return self._config_error
|
||||
|
||||
def _ensure_configured(self) -> None:
|
||||
self._refresh_configuration()
|
||||
if not self.api or not self.is_configured:
|
||||
raise RemnaWaveAPIError(
|
||||
self._config_error or "RemnaWave API не настроен"
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_api_client(self):
|
||||
self._ensure_configured()
|
||||
assert self.api is not None
|
||||
async with self.api as api:
|
||||
yield api
|
||||
|
||||
async def create_remnawave_user(
|
||||
self,
|
||||
@@ -106,7 +171,7 @@ class SubscriptionService:
|
||||
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
|
||||
@@ -216,7 +281,7 @@ class SubscriptionService:
|
||||
is_actually_active = False
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
updated_user = await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
@@ -281,7 +346,7 @@ class SubscriptionService:
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
await api.disable_user(user_uuid)
|
||||
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
|
||||
return True
|
||||
@@ -301,7 +366,7 @@ class SubscriptionService:
|
||||
if not user or not user.remnawave_uuid:
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
updated_user = await api.revoke_user_subscription(user.remnawave_uuid)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
@@ -319,7 +384,7 @@ class SubscriptionService:
|
||||
async def get_subscription_info(self, short_uuid: str) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
info = await api.get_subscription_info(short_uuid)
|
||||
return info
|
||||
|
||||
@@ -338,7 +403,7 @@ class SubscriptionService:
|
||||
if not user or not user.remnawave_uuid:
|
||||
return False
|
||||
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if not remnawave_user:
|
||||
return False
|
||||
@@ -585,7 +650,7 @@ class SubscriptionService:
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
async with self.get_api_client() as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
if not remnawave_user:
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.database.universal_migration import ensure_default_web_api_token
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import Settings, settings
|
||||
from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices
|
||||
from app.database.crud.system_setting import (
|
||||
delete_system_setting,
|
||||
upsert_system_setting,
|
||||
@@ -60,92 +60,158 @@ class BotConfigurationService:
|
||||
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
|
||||
|
||||
CATEGORY_TITLES: Dict[str, str] = {
|
||||
"SUPPORT": "💬 Ссылка на поддержку",
|
||||
"LOCALIZATION": "🌍 Языковые настройки",
|
||||
"MAINTENANCE": "🛠️ Режим обслуживания",
|
||||
"CORE": "🤖 Основные настройки",
|
||||
"SUPPORT": "💬 Поддержка и тикеты",
|
||||
"LOCALIZATION": "🌍 Языки интерфейса",
|
||||
"CHANNEL": "📣 Обязательная подписка",
|
||||
"ADMIN_NOTIFICATIONS": "🔔 Уведомления админам",
|
||||
"ADMIN_REPORTS": "📊 Автоматические отчеты",
|
||||
"TRIAL": "🎁 Триальная подписка",
|
||||
"PAID_SUBSCRIPTION": "💰 Платные подписки",
|
||||
"PERIODS": "📅 Периоды подписки",
|
||||
"SUBSCRIPTION_PRICES": "💵 Цены за периоды",
|
||||
"TRAFFIC": "🚦 Настройки трафика",
|
||||
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
|
||||
"DISCOUNTS": "🎯 Промо и скидки",
|
||||
"PAYMENT": "⚙️ Общие настройки платежей",
|
||||
"PAYMENT": "💳 Общие платежные настройки",
|
||||
"TELEGRAM": "⭐ Telegram Stars",
|
||||
"CRYPTOBOT": "💎 CryptoBot",
|
||||
"YOOKASSA": "💸 YooKassa",
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 MulenPay",
|
||||
"PAL24": "🏦 Pal24/PayPalych",
|
||||
"REMNAWAVE": "🔗 RemnaWave API",
|
||||
"REFERRAL": "🤝 Реферальная система",
|
||||
"PAL24": "🏦 PAL24 / PayPalych",
|
||||
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
|
||||
"PERIODS": "📆 Периоды подписок",
|
||||
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
|
||||
"TRAFFIC": "📊 Трафик",
|
||||
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
|
||||
"TRIAL": "🎁 Пробный период",
|
||||
"REFERRAL": "👥 Реферальная программа",
|
||||
"AUTOPAY": "🔄 Автопродление",
|
||||
"INTERFACE_BRANDING": "🖼️ Визуальные настройки",
|
||||
"INTERFACE_SUBSCRIPTION": "🔗 Скрыть ссылку подписки",
|
||||
"CONNECT_BUTTON": "🚀 Кнопка «Подключиться»",
|
||||
"HAPP": "🅷 Happ настройки",
|
||||
"SKIP": "⚡ Быстрый старт",
|
||||
"ADDITIONAL": "📱 Приложения и DeepLinks",
|
||||
"MINIAPP": "📱 Mini App",
|
||||
"DATABASE": "🗄️ Режим БД",
|
||||
"POSTGRES": "🐘 PostgreSQL",
|
||||
"SQLITE": "💾 SQLite",
|
||||
"REDIS": "🧠 Redis",
|
||||
"MONITORING": "📈 Общий мониторинг",
|
||||
"NOTIFICATIONS": "🔔 Уведомления пользователям",
|
||||
"SERVER": "🖥️ Статус серверов",
|
||||
"BACKUP": "💾 Система бэкапов",
|
||||
"VERSION": "🔄 Обновления",
|
||||
"LOG": "📝 Логирование",
|
||||
"ADMIN_NOTIFICATIONS": "📣 Оповещения администраторам",
|
||||
"ADMIN_REPORTS": "🗂 Автоматические отчеты",
|
||||
"INTERFACE": "🎨 Интерфейс и брендинг",
|
||||
"INTERFACE_BRANDING": "🖼️ Брендинг",
|
||||
"INTERFACE_SUBSCRIPTION": "🔗 Ссылка на подписку",
|
||||
"CONNECT_BUTTON": "🚀 Кнопка подключения",
|
||||
"MINIAPP": "📱 Mini App",
|
||||
"HAPP": "🅷 Happ",
|
||||
"SKIP": "⚡ Быстрый старт",
|
||||
"ADDITIONAL": "📱 Дополнительные приложения",
|
||||
"DATABASE": "💾 База данных",
|
||||
"POSTGRES": "🐘 PostgreSQL",
|
||||
"SQLITE": "🧱 SQLite",
|
||||
"REDIS": "🧠 Redis",
|
||||
"REMNAWAVE": "🌐 RemnaWave API",
|
||||
"SERVER_STATUS": "📊 Статус серверов",
|
||||
"MONITORING": "📈 Мониторинг",
|
||||
"MAINTENANCE": "🔧 Обслуживание",
|
||||
"BACKUP": "💾 Резервные копии",
|
||||
"VERSION": "🔄 Проверка версий",
|
||||
"WEB_API": "⚡ Web API",
|
||||
"WEBHOOK": "🌐 Webhook",
|
||||
"WEB_API": "🌐 Web API",
|
||||
"DEBUG": "🔧 Режим разработки",
|
||||
"LOG": "📝 Логирование",
|
||||
"DEBUG": "🧪 Режим разработки",
|
||||
}
|
||||
|
||||
CATEGORY_DESCRIPTIONS: Dict[str, str] = {
|
||||
"CORE": "Базовые параметры работы бота и обязательные ссылки.",
|
||||
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
|
||||
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
|
||||
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
|
||||
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"MULENPAY": "Платежи MulenPay и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
"TELEGRAM": "Telegram Stars и их стоимость.",
|
||||
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
|
||||
"PERIODS": "Доступные периоды подписок и продлений.",
|
||||
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
|
||||
"TRAFFIC": "Лимиты трафика и стратегии сброса.",
|
||||
"TRAFFIC_PACKAGES": "Цены пакетов трафика и конфигурация предложений.",
|
||||
"TRIAL": "Длительность и ограничения пробного периода.",
|
||||
"REFERRAL": "Бонусы и пороги реферальной программы.",
|
||||
"AUTOPAY": "Настройки автопродления и минимальный баланс.",
|
||||
"NOTIFICATIONS": "Пользовательские уведомления и кэширование сообщений.",
|
||||
"ADMIN_NOTIFICATIONS": "Оповещения админам о событиях и тикетах.",
|
||||
"ADMIN_REPORTS": "Автоматические отчеты для команды.",
|
||||
"INTERFACE": "Глобальные параметры интерфейса и брендирования.",
|
||||
"INTERFACE_BRANDING": "Логотип и фирменный стиль.",
|
||||
"INTERFACE_SUBSCRIPTION": "Отображение ссылок и кнопок подписок.",
|
||||
"CONNECT_BUTTON": "Поведение кнопки «Подключиться» и miniapp.",
|
||||
"MINIAPP": "Mini App и кастомные ссылки.",
|
||||
"HAPP": "Интеграция Happ и связанные ссылки.",
|
||||
"SKIP": "Настройки быстрого старта и гайд по подключению.",
|
||||
"ADDITIONAL": "Конфигурация app-config.json, deep links и кеша.",
|
||||
"DATABASE": "Режим работы базы данных и пути до файлов.",
|
||||
"POSTGRES": "Параметры подключения к PostgreSQL.",
|
||||
"SQLITE": "Файл SQLite и резервные параметры.",
|
||||
"REDIS": "Подключение к Redis для кэша.",
|
||||
"REMNAWAVE": "Параметры авторизации и интеграция с RemnaWave API.",
|
||||
"SERVER_STATUS": "Отображение статуса серверов и external URL.",
|
||||
"MONITORING": "Интервалы мониторинга и хранение логов.",
|
||||
"MAINTENANCE": "Режим обслуживания, сообщения и интервалы.",
|
||||
"BACKUP": "Резервное копирование и расписание.",
|
||||
"VERSION": "Отслеживание обновлений репозитория.",
|
||||
"WEB_API": "Web API, токены и права доступа.",
|
||||
"WEBHOOK": "Пути и секреты вебхуков.",
|
||||
"LOG": "Уровни логирования и ротация.",
|
||||
"DEBUG": "Отладочные функции и безопасный режим.",
|
||||
}
|
||||
|
||||
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
|
||||
"DATABASE_URL": "DATABASE",
|
||||
"DATABASE_MODE": "DATABASE",
|
||||
"LOCALES_PATH": "DATABASE",
|
||||
"DEFAULT_DEVICE_LIMIT": "PAID_SUBSCRIPTION",
|
||||
"DEFAULT_TRAFFIC_LIMIT_GB": "PAID_SUBSCRIPTION",
|
||||
"MAX_DEVICES_LIMIT": "PAID_SUBSCRIPTION",
|
||||
"PRICE_PER_DEVICE": "PAID_SUBSCRIPTION",
|
||||
"LOCALES_PATH": "LOCALIZATION",
|
||||
"CHANNEL_SUB_ID": "CHANNEL",
|
||||
"CHANNEL_LINK": "CHANNEL",
|
||||
"CHANNEL_IS_REQUIRED_SUB": "CHANNEL",
|
||||
"DEFAULT_LANGUAGE": "LOCALIZATION",
|
||||
"AVAILABLE_LANGUAGES": "LOCALIZATION",
|
||||
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
|
||||
"DEFAULT_DEVICE_LIMIT": "SUBSCRIPTIONS_CORE",
|
||||
"DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE",
|
||||
"MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE",
|
||||
"PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
|
||||
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
|
||||
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
|
||||
"FIXED_TRAFFIC_LIMIT_GB": "TRAFFIC",
|
||||
"AVAILABLE_SUBSCRIPTION_PERIODS": "PERIODS",
|
||||
"AVAILABLE_RENEWAL_PERIODS": "PERIODS",
|
||||
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_14_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_30_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_60_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
|
||||
"PRICE_TRAFFIC_5GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_10GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_25GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_50GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_100GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_250GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_500GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_1000GB": "TRAFFIC_PACKAGES",
|
||||
"PRICE_TRAFFIC_UNLIMITED": "TRAFFIC_PACKAGES",
|
||||
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "DISCOUNTS",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "DISCOUNTS",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
|
||||
"REFERRED_USER_REWARD": "REFERRAL",
|
||||
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
|
||||
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
|
||||
"TRIAL_WARNING_HOURS": "NOTIFICATIONS",
|
||||
"TRIAL_WARNING_HOURS": "TRIAL",
|
||||
"SUPPORT_USERNAME": "SUPPORT",
|
||||
"SUPPORT_MENU_ENABLED": "SUPPORT",
|
||||
"SUPPORT_SYSTEM_MODE": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_ENABLED": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_MINUTES": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS": "SUPPORT",
|
||||
"SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES": "SUPPORT",
|
||||
"ADMIN_NOTIFICATIONS_ENABLED": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_NOTIFICATIONS_CHAT_ID": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_NOTIFICATIONS_TOPIC_ID": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID": "ADMIN_NOTIFICATIONS",
|
||||
"ADMIN_REPORTS_ENABLED": "ADMIN_REPORTS",
|
||||
"ADMIN_REPORTS_CHAT_ID": "ADMIN_REPORTS",
|
||||
"ADMIN_REPORTS_TOPIC_ID": "ADMIN_REPORTS",
|
||||
"ADMIN_REPORTS_SEND_TIME": "ADMIN_REPORTS",
|
||||
"PAYMENT_SERVICE_NAME": "PAYMENT",
|
||||
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
|
||||
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
|
||||
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
|
||||
"MONITORING_INTERVAL": "MONITORING",
|
||||
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
|
||||
"LOGO_FILE": "INTERFACE_BRANDING",
|
||||
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
|
||||
@@ -154,15 +220,17 @@ class BotConfigurationService:
|
||||
"APP_CONFIG_PATH": "ADDITIONAL",
|
||||
"ENABLE_DEEP_LINKS": "ADDITIONAL",
|
||||
"APP_CONFIG_CACHE_TTL": "ADDITIONAL",
|
||||
"DEFAULT_LANGUAGE": "LOCALIZATION",
|
||||
"AVAILABLE_LANGUAGES": "LOCALIZATION",
|
||||
"PAYMENT_SERVICE_NAME": "PAYMENT",
|
||||
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
|
||||
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
|
||||
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
|
||||
"INACTIVE_USER_DELETE_MONTHS": "MONITORING",
|
||||
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
|
||||
"INACTIVE_USER_DELETE_MONTHS": "MAINTENANCE",
|
||||
"MAINTENANCE_MESSAGE": "MAINTENANCE",
|
||||
"MAINTENANCE_CHECK_INTERVAL": "MAINTENANCE",
|
||||
"MAINTENANCE_AUTO_ENABLE": "MAINTENANCE",
|
||||
"WEBHOOK_URL": "WEBHOOK",
|
||||
"WEBHOOK_SECRET": "WEBHOOK",
|
||||
"VERSION_CHECK_ENABLED": "VERSION",
|
||||
"VERSION_CHECK_REPO": "VERSION",
|
||||
"VERSION_CHECK_INTERVAL_HOURS": "VERSION",
|
||||
"TELEGRAM_STARS_RATE_RUB": "TELEGRAM",
|
||||
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE",
|
||||
}
|
||||
|
||||
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
|
||||
@@ -193,7 +261,7 @@ class BotConfigurationService:
|
||||
"MINIAPP_": "MINIAPP",
|
||||
"MONITORING_": "MONITORING",
|
||||
"NOTIFICATION_": "NOTIFICATIONS",
|
||||
"SERVER_STATUS": "SERVER",
|
||||
"SERVER_STATUS": "SERVER_STATUS",
|
||||
"MAINTENANCE_": "MAINTENANCE",
|
||||
"VERSION_CHECK": "VERSION",
|
||||
"BACKUP_": "BACKUP",
|
||||
@@ -290,6 +358,153 @@ class BotConfigurationService:
|
||||
],
|
||||
}
|
||||
|
||||
SETTING_HINTS: Dict[str, Dict[str, str]] = {
|
||||
"YOOKASSA_ENABLED": {
|
||||
"description": "Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.",
|
||||
"format": "Булево значение: выберите \"Включить\" или \"Выключить\".",
|
||||
"example": "Включено при полностью настроенной интеграции.",
|
||||
"warning": "При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.",
|
||||
"dependencies": "YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL",
|
||||
},
|
||||
"CRYPTOBOT_ENABLED": {
|
||||
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
|
||||
"format": "Булево значение.",
|
||||
"example": "Включите после указания токена API и секрета вебхука.",
|
||||
"warning": "Пустой токен или неверный вебхук приведут к отказам платежей.",
|
||||
"dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET",
|
||||
},
|
||||
"SUPPORT_TICKET_SLA_MINUTES": {
|
||||
"description": "Лимит времени для ответа модераторов на тикет в минутах.",
|
||||
"format": "Целое число от 1 до 1440.",
|
||||
"example": "5",
|
||||
"warning": "Слишком низкое значение может вызвать частые напоминания, слишком высокое — ухудшить SLA.",
|
||||
"dependencies": "SUPPORT_TICKET_SLA_ENABLED, SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES",
|
||||
},
|
||||
"MAINTENANCE_MODE": {
|
||||
"description": "Переводит бота в режим технического обслуживания и скрывает действия для пользователей.",
|
||||
"format": "Булево значение.",
|
||||
"example": "Включено на время плановых работ.",
|
||||
"warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.",
|
||||
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
|
||||
},
|
||||
"REMNAWAVE_API_URL": {
|
||||
"description": "Базовый адрес панели RemnaWave, с которой синхронизируется бот.",
|
||||
"format": "Полный URL вида https://panel.example.com.",
|
||||
"example": "https://panel.remnawave.net",
|
||||
"warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.",
|
||||
"dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_category_description(cls, category_key: str) -> str:
|
||||
return cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
|
||||
|
||||
@classmethod
|
||||
def is_toggle(cls, key: str) -> bool:
|
||||
definition = cls.get_definition(key)
|
||||
return definition.python_type is bool
|
||||
|
||||
@classmethod
|
||||
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
upper_key = key.upper()
|
||||
if any(suffix in upper_key for suffix in ("PRICE", "_KOPEKS", "AMOUNT")):
|
||||
try:
|
||||
return settings.format_price(int(value))
|
||||
except Exception:
|
||||
return f"{value}"
|
||||
if upper_key.endswith("_PERCENT") or "PERCENT" in upper_key:
|
||||
return f"{value}%"
|
||||
if upper_key.endswith("_HOURS"):
|
||||
return f"{value} ч"
|
||||
if upper_key.endswith("_MINUTES"):
|
||||
return f"{value} мин"
|
||||
if upper_key.endswith("_SECONDS"):
|
||||
return f"{value} сек"
|
||||
if upper_key.endswith("_DAYS"):
|
||||
return f"{value} дн"
|
||||
if upper_key.endswith("_GB"):
|
||||
return f"{value} ГБ"
|
||||
if upper_key.endswith("_MB"):
|
||||
return f"{value} МБ"
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _split_comma_values(cls, text: str) -> Optional[List[str]]:
|
||||
raw = (text or "").strip()
|
||||
if not raw or "," not in raw:
|
||||
return None
|
||||
parts = [segment.strip() for segment in raw.split(",") if segment.strip()]
|
||||
return parts or None
|
||||
|
||||
@classmethod
|
||||
def format_value_human(cls, key: str, value: Any) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
if isinstance(value, bool):
|
||||
return "✅ ВКЛЮЧЕНО" if value else "❌ ВЫКЛЮЧЕНО"
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
formatted = cls._format_numeric_with_unit(key, value)
|
||||
return formatted or str(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
return "—"
|
||||
if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")):
|
||||
return "••••••••"
|
||||
items = cls._split_comma_values(cleaned)
|
||||
if items:
|
||||
return ", ".join(items)
|
||||
return cleaned
|
||||
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return ", ".join(str(item) for item in value)
|
||||
|
||||
if isinstance(value, dict):
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def get_setting_guidance(cls, key: str) -> Dict[str, str]:
|
||||
definition = cls.get_definition(key)
|
||||
original = cls.get_original_value(key)
|
||||
type_label = definition.type_label
|
||||
hints = dict(cls.SETTING_HINTS.get(key, {}))
|
||||
|
||||
base_description = (
|
||||
hints.get("description")
|
||||
or f"Параметр <b>{definition.display_name}</b> управляет категорией «{definition.category_label}»."
|
||||
)
|
||||
base_format = hints.get("format") or (
|
||||
"Булево значение (да/нет)." if definition.python_type is bool
|
||||
else "Введите значение соответствующего типа (число или строку)."
|
||||
)
|
||||
example = hints.get("example") or (
|
||||
cls.format_value_human(key, original) if original is not None else "—"
|
||||
)
|
||||
warning = hints.get("warning") or (
|
||||
"Неверные значения могут привести к некорректной работе бота."
|
||||
)
|
||||
dependencies = hints.get("dependencies") or definition.category_label
|
||||
|
||||
return {
|
||||
"description": base_description,
|
||||
"format": base_format,
|
||||
"example": example,
|
||||
"warning": warning,
|
||||
"dependencies": dependencies,
|
||||
"type": type_label,
|
||||
}
|
||||
|
||||
_definitions: Dict[str, SettingDefinition] = {}
|
||||
_original_values: Dict[str, Any] = settings.model_dump()
|
||||
_overrides_raw: Dict[str, Optional[str]] = {}
|
||||
@@ -443,7 +658,7 @@ class BotConfigurationService:
|
||||
@classmethod
|
||||
def format_value_for_list(cls, key: str) -> str:
|
||||
value = cls.get_current_value(key)
|
||||
formatted = cls.format_value(value)
|
||||
formatted = cls.format_value_human(key, value)
|
||||
if formatted == "—":
|
||||
return formatted
|
||||
return _truncate(formatted)
|
||||
@@ -668,6 +883,17 @@ class BotConfigurationService:
|
||||
def _apply_to_settings(cls, key: str, value: Any) -> None:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
if key in {
|
||||
"PRICE_14_DAYS",
|
||||
"PRICE_30_DAYS",
|
||||
"PRICE_60_DAYS",
|
||||
"PRICE_90_DAYS",
|
||||
"PRICE_180_DAYS",
|
||||
"PRICE_360_DAYS",
|
||||
}:
|
||||
refresh_period_prices()
|
||||
elif key.startswith("PRICE_TRAFFIC_") or key == "TRAFFIC_PACKAGES_CONFIG":
|
||||
refresh_traffic_prices()
|
||||
except Exception as error:
|
||||
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
|
||||
|
||||
@@ -693,8 +919,8 @@ class BotConfigurationService:
|
||||
return {
|
||||
"key": key,
|
||||
"name": definition.display_name,
|
||||
"current": cls.format_value(current),
|
||||
"original": cls.format_value(original),
|
||||
"current": cls.format_value_human(key, current),
|
||||
"original": cls.format_value_human(key, original),
|
||||
"type": definition.type_label,
|
||||
"category_key": definition.category_key,
|
||||
"category_label": definition.category_label,
|
||||
|
||||
+16
-2
@@ -97,14 +97,22 @@ 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()
|
||||
|
||||
# Состояния для отслеживания источника перехода
|
||||
viewing_user_from_balance_list = State()
|
||||
@@ -132,6 +140,12 @@ class SupportSettingsStates(StatesGroup):
|
||||
|
||||
class BotConfigStates(StatesGroup):
|
||||
waiting_for_value = State()
|
||||
waiting_for_search_query = State()
|
||||
waiting_for_import_file = State()
|
||||
|
||||
|
||||
class PricingStates(StatesGroup):
|
||||
waiting_for_value = State()
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,184 @@
|
||||
import platform
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepRecord:
|
||||
title: str
|
||||
icon: str
|
||||
status_label: str
|
||||
message: str
|
||||
duration: float
|
||||
|
||||
|
||||
class StageHandle:
|
||||
def __init__(
|
||||
self,
|
||||
timeline: "StartupTimeline",
|
||||
title: str,
|
||||
icon: str,
|
||||
success_message: Optional[str],
|
||||
) -> None:
|
||||
self.timeline = timeline
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.message = success_message or ""
|
||||
self.status_icon = "✅"
|
||||
self.status_label = "Готово"
|
||||
self._explicit_status = False
|
||||
|
||||
def success(self, message: Optional[str] = None) -> None:
|
||||
if message is not None:
|
||||
self.message = message
|
||||
self.status_icon = "✅"
|
||||
self.status_label = "Готово"
|
||||
self._explicit_status = True
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
self.status_icon = "⚠️"
|
||||
self.status_label = "Предупреждение"
|
||||
self.message = message
|
||||
self._explicit_status = True
|
||||
|
||||
def skip(self, message: str) -> None:
|
||||
self.status_icon = "⏭️"
|
||||
self.status_label = "Пропущено"
|
||||
self.message = message
|
||||
self._explicit_status = True
|
||||
|
||||
def failure(self, message: str) -> None:
|
||||
self.status_icon = "❌"
|
||||
self.status_label = "Ошибка"
|
||||
self.message = message
|
||||
self._explicit_status = True
|
||||
|
||||
def log(self, message: str, icon: str = "•") -> None:
|
||||
self.timeline.logger.info(f"┃ {icon} {message}")
|
||||
|
||||
|
||||
class StartupTimeline:
|
||||
def __init__(self, logger: Any, app_name: str) -> None:
|
||||
self.logger = logger
|
||||
self.app_name = app_name
|
||||
self.steps: List[StepRecord] = []
|
||||
|
||||
def _record_step(
|
||||
self, title: str, icon: str, status_label: str, message: str, duration: float
|
||||
) -> None:
|
||||
self.steps.append(
|
||||
StepRecord(
|
||||
title=title,
|
||||
icon=icon,
|
||||
status_label=status_label,
|
||||
message=message,
|
||||
duration=duration,
|
||||
)
|
||||
)
|
||||
|
||||
def log_banner(self, metadata: Optional[Sequence[Tuple[str, Any]]] = None) -> None:
|
||||
title_text = f"🚀 {self.app_name}"
|
||||
subtitle_parts = [f"Python {platform.python_version()}"]
|
||||
if metadata:
|
||||
for key, value in metadata:
|
||||
subtitle_parts.append(f"{key}: {value}")
|
||||
subtitle_text = " | ".join(subtitle_parts)
|
||||
|
||||
width = max(len(title_text), len(subtitle_text))
|
||||
border = "╔" + "═" * (width + 2) + "╗"
|
||||
self.logger.info(border)
|
||||
self.logger.info("║ " + title_text.ljust(width) + " ║")
|
||||
self.logger.info("║ " + subtitle_text.ljust(width) + " ║")
|
||||
self.logger.info("╚" + "═" * (width + 2) + "╝")
|
||||
|
||||
def log_section(self, title: str, lines: Iterable[str], icon: str = "📄") -> None:
|
||||
items = [f"{icon} {title}"] + [f"• {line}" for line in lines]
|
||||
width = max(len(item) for item in items)
|
||||
top = "┌ " + "─" * width + " ┐"
|
||||
middle = "├ " + "─" * width + " ┤"
|
||||
bottom = "└ " + "─" * width + " ┘"
|
||||
|
||||
self.logger.info(top)
|
||||
self.logger.info("│ " + items[0].ljust(width) + " │")
|
||||
self.logger.info(middle)
|
||||
for item in items[1:]:
|
||||
self.logger.info("│ " + item.ljust(width) + " │")
|
||||
self.logger.info(bottom)
|
||||
|
||||
def add_manual_step(
|
||||
self,
|
||||
title: str,
|
||||
icon: str,
|
||||
status_label: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
self.logger.info(f"┏ {icon} {title}")
|
||||
self.logger.info(f"┗ {icon} {title} — {status_label}: {message}")
|
||||
self._record_step(title, icon, status_label, message, 0.0)
|
||||
|
||||
@asynccontextmanager
|
||||
async def stage(
|
||||
self,
|
||||
title: str,
|
||||
icon: str = "⚙️",
|
||||
description: Optional[str] = None,
|
||||
success_message: Optional[str] = "Готово",
|
||||
):
|
||||
if description:
|
||||
self.logger.info(f"┏ {icon} {title} — {description}")
|
||||
else:
|
||||
self.logger.info(f"┏ {icon} {title}")
|
||||
|
||||
handle = StageHandle(self, title, icon, success_message)
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
yield handle
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
handle.failure(message)
|
||||
self.logger.exception(f"┣ ❌ {title} — ошибка: {message}")
|
||||
raise
|
||||
finally:
|
||||
duration = time.perf_counter() - start_time
|
||||
if not handle._explicit_status:
|
||||
handle.success(handle.message or "Готово")
|
||||
self.logger.info(
|
||||
f"┗ {handle.status_icon} {title} — {handle.message} [{duration:.2f}s]"
|
||||
)
|
||||
self._record_step(
|
||||
title=title,
|
||||
icon=handle.status_icon,
|
||||
status_label=handle.status_label,
|
||||
message=handle.message,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
def log_summary(self) -> None:
|
||||
if not self.steps:
|
||||
return
|
||||
|
||||
lines = []
|
||||
for step in self.steps:
|
||||
base = (
|
||||
f"{step.icon} {step.title} — {step.status_label}"
|
||||
f" [{step.duration:.2f}s]"
|
||||
)
|
||||
if step.message:
|
||||
base += f" :: {step.message}"
|
||||
lines.append(base)
|
||||
|
||||
width = max(len(line) for line in lines)
|
||||
border_top = "┏" + "━" * (width + 2) + "┓"
|
||||
border_mid = "┣" + "━" * (width + 2) + "┫"
|
||||
border_bottom = "┗" + "━" * (width + 2) + "┛"
|
||||
title = "РЕЗЮМЕ ЗАПУСКА"
|
||||
|
||||
self.logger.info(border_top)
|
||||
self.logger.info("┃ " + title.center(width) + " ┃")
|
||||
self.logger.info(border_mid)
|
||||
for line in lines:
|
||||
self.logger.info("┃ " + line.ljust(width) + " ┃")
|
||||
self.logger.info(border_bottom)
|
||||
|
||||
Executable
+1454
File diff suppressed because it is too large
Load Diff
+142
-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",
|
||||
@@ -527,14 +529,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):",
|
||||
@@ -543,6 +557,8 @@
|
||||
"NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
|
||||
"ADMIN_MAIN_SERVERS": "🌐 Servers",
|
||||
"ADMIN_MAIN_PRICING": "💰 Pricing",
|
||||
"ADMIN_MAIN_PROMO_STATS": "💰 Promo codes / Stats",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Support",
|
||||
"ADMIN_MAIN_MESSAGES": "📨 Messages",
|
||||
@@ -555,6 +571,70 @@
|
||||
"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_SUPPORT_TICKETS": "🎫 Support tickets",
|
||||
"ADMIN_SUPPORT_AUDIT": "🧾 Moderator audit",
|
||||
"ADMIN_SUPPORT_SETTINGS": "🛟 Support settings",
|
||||
@@ -815,5 +895,62 @@
|
||||
"ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Connect",
|
||||
"ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Subscription",
|
||||
"ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Support",
|
||||
"ADMIN_BROADCAST_BUTTON_HOME": "🏠 Main menu"
|
||||
"ADMIN_BROADCAST_BUTTON_HOME": "🏠 Main menu",
|
||||
"ADMIN_PRICING_SUMMARY_EMPTY": "—",
|
||||
"ADMIN_PRICING_MENU_TITLE": "Pricing management",
|
||||
"ADMIN_PRICING_MENU_DESCRIPTION": "Quick access to subscription plans, traffic bundles and extra services.",
|
||||
"ADMIN_PRICING_MENU_SUMMARY": "Quick summary:",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_CORE": "• Core limits: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_EXTRA": "• Extras: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_PERIOD_OPTIONS": "• Available periods: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_PERIODS": "• Periods: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_TRAFFIC": "• Traffic: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_TRIAL": "• Trial: {summary}",
|
||||
"ADMIN_PRICING_MENU_PROMPT": "Choose a section to edit:",
|
||||
"ADMIN_PRICING_BUTTON_CORE": "⚙️ Core limits",
|
||||
"ADMIN_PRICING_BUTTON_EXTRA": "➕ Extras",
|
||||
"ADMIN_PRICING_BUTTON_PERIOD_OPTIONS": "🗓 Available periods",
|
||||
"ADMIN_PRICING_BUTTON_PERIODS": "🗓 Subscription periods",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC": "📦 Traffic packages",
|
||||
"ADMIN_PRICING_BUTTON_TRIAL": "🎁 Trial period",
|
||||
"ADMIN_PRICING_CHOICE_ALREADY": "This option is already active.",
|
||||
"ADMIN_PRICING_CHOICE_UPDATED": "Selected: {label}",
|
||||
"ADMIN_PRICING_EDIT_CANCEL": "❌ Cancel",
|
||||
"ADMIN_PRICING_EDIT_CANCEL_HINT": "Send \"Cancel\" to return without changes.",
|
||||
"ADMIN_PRICING_EDIT_TITLE": "Update price",
|
||||
"ADMIN_PRICING_EDIT_TARGET": "Current item",
|
||||
"ADMIN_PRICING_EDIT_CURRENT": "Current value",
|
||||
"ADMIN_PRICING_EDIT_PROMPT": "Enter a new price in RUB (e.g. 990 or 990.50). Use 0 for a free plan.",
|
||||
"ADMIN_PRICING_EDIT_EXPIRED": "Editing session expired.",
|
||||
"ADMIN_PRICING_EDIT_CANCELLED": "Changes cancelled.",
|
||||
"ADMIN_PRICING_EDIT_INVALID": "Could not parse the price. Please enter a number in RUB (e.g. 990 or 990.50).",
|
||||
"ADMIN_PRICING_EDIT_SUCCESS": "Price for {item} updated: {price}",
|
||||
"ADMIN_PRICING_PERIOD_DISABLED": "Period disabled.",
|
||||
"ADMIN_PRICING_PERIOD_ENABLED": "Period enabled.",
|
||||
"ADMIN_PRICING_PERIOD_MIN": "At least one period must remain.",
|
||||
"ADMIN_PRICING_SECTION_CORE_TITLE": "⚙️ Core limits",
|
||||
"ADMIN_PRICING_SECTION_CURRENT": "Current values:",
|
||||
"ADMIN_PRICING_SECTION_EMPTY": "No values available.",
|
||||
"ADMIN_PRICING_SECTION_EXTRA_TITLE": "➕ Extra options",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_PROMPT": "Tap a period to toggle its visibility.",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_RENEW": "Active renewal periods: {items}",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_SUB": "Active subscription periods: {items}",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_TITLE": "🗓 Available periods",
|
||||
"ADMIN_PRICING_SECTION_PERIODS_TITLE": "🗓 Subscription periods",
|
||||
"ADMIN_PRICING_SECTION_PROMPT": "Select what to update:",
|
||||
"ADMIN_PRICING_SECTION_SETTINGS_GENERIC": "⚙️ Settings",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_TITLE": "📦 Traffic packages",
|
||||
"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",
|
||||
"ADMIN_PRICING_SETTING_EDIT_TITLE": "Parameter configuration",
|
||||
"ADMIN_PRICING_SETTING_EDIT_WITH_VALUE": "✏️ {label} • {value}",
|
||||
"ADMIN_PRICING_SETTING_EXAMPLE": "Example",
|
||||
"ADMIN_PRICING_SETTING_FORMAT": "Input format",
|
||||
"ADMIN_PRICING_SETTING_INVALID": "Could not update the parameter. Please check the value format.",
|
||||
"ADMIN_PRICING_SETTING_PARAMETER": "Parameter",
|
||||
"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"
|
||||
}
|
||||
|
||||
+142
-5
@@ -235,6 +235,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": "🔧 Управление устройствами",
|
||||
"MENU_ADMIN": "⚙️ Админ-панель",
|
||||
@@ -527,14 +529,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):",
|
||||
@@ -543,6 +557,8 @@
|
||||
"NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
|
||||
"ADMIN_MAIN_SERVERS": "🌐 Серверы",
|
||||
"ADMIN_MAIN_PRICING": "💰 Цены",
|
||||
"ADMIN_MAIN_PROMO_STATS": "💰 Промокоды/Статистика",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
|
||||
"ADMIN_MAIN_MESSAGES": "📨 Сообщения",
|
||||
@@ -555,6 +571,70 @@
|
||||
"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_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
|
||||
"ADMIN_SUPPORT_AUDIT": "🧾 Аудит модераторов",
|
||||
"ADMIN_SUPPORT_SETTINGS": "🛟 Настройки поддержки",
|
||||
@@ -815,5 +895,62 @@
|
||||
"ADMIN_BROADCAST_BUTTON_CONNECT": "🔗 Подключиться",
|
||||
"ADMIN_BROADCAST_BUTTON_SUBSCRIPTION": "📱 Подписка",
|
||||
"ADMIN_BROADCAST_BUTTON_SUPPORT": "🛠️ Техподдержка",
|
||||
"ADMIN_BROADCAST_BUTTON_HOME": "🏠 На главную"
|
||||
"ADMIN_BROADCAST_BUTTON_HOME": "🏠 На главную",
|
||||
"ADMIN_PRICING_SUMMARY_EMPTY": "—",
|
||||
"ADMIN_PRICING_MENU_TITLE": "Управление ценами",
|
||||
"ADMIN_PRICING_MENU_DESCRIPTION": "Быстрый доступ к тарифам подписок, пакетам трафика и дополнительным услугам.",
|
||||
"ADMIN_PRICING_MENU_SUMMARY": "Краткая сводка:",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_CORE": "• Базовые лимиты: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_EXTRA": "• Дополнительно: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_PERIOD_OPTIONS": "• Доступные периоды: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_PERIODS": "• Периоды: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_TRAFFIC": "• Трафик: {summary}",
|
||||
"ADMIN_PRICING_MENU_SUMMARY_TRIAL": "• Триал: {summary}",
|
||||
"ADMIN_PRICING_MENU_PROMPT": "Выберите раздел для редактирования:",
|
||||
"ADMIN_PRICING_BUTTON_CORE": "⚙️ Базовые лимиты",
|
||||
"ADMIN_PRICING_BUTTON_EXTRA": "➕ Дополнительно",
|
||||
"ADMIN_PRICING_BUTTON_PERIOD_OPTIONS": "🗓 Доступные периоды",
|
||||
"ADMIN_PRICING_BUTTON_PERIODS": "🗓 Периоды подписки",
|
||||
"ADMIN_PRICING_BUTTON_TRAFFIC": "📦 Пакеты трафика",
|
||||
"ADMIN_PRICING_BUTTON_TRIAL": "🎁 Пробный период",
|
||||
"ADMIN_PRICING_CHOICE_ALREADY": "Это значение уже активно.",
|
||||
"ADMIN_PRICING_CHOICE_UPDATED": "Выбрано: {label}",
|
||||
"ADMIN_PRICING_EDIT_CANCEL": "❌ Отмена",
|
||||
"ADMIN_PRICING_EDIT_CANCEL_HINT": "Напишите «Отмена», чтобы вернуться без изменений.",
|
||||
"ADMIN_PRICING_EDIT_TITLE": "Изменение цены",
|
||||
"ADMIN_PRICING_EDIT_TARGET": "Текущий тариф",
|
||||
"ADMIN_PRICING_EDIT_CURRENT": "Текущее значение",
|
||||
"ADMIN_PRICING_EDIT_PROMPT": "Введите новую стоимость в рублях (например 990 или 990.50). Для бесплатного тарифа укажите 0.",
|
||||
"ADMIN_PRICING_EDIT_EXPIRED": "Сессия редактирования истекла.",
|
||||
"ADMIN_PRICING_EDIT_CANCELLED": "Изменения отменены.",
|
||||
"ADMIN_PRICING_EDIT_INVALID": "Не удалось распознать цену. Укажите число в рублях (например 990 или 990.50).",
|
||||
"ADMIN_PRICING_EDIT_SUCCESS": "Цена для {item} обновлена: {price}",
|
||||
"ADMIN_PRICING_PERIOD_DISABLED": "Период отключен.",
|
||||
"ADMIN_PRICING_PERIOD_ENABLED": "Период включен.",
|
||||
"ADMIN_PRICING_PERIOD_MIN": "Должен оставаться хотя бы один период.",
|
||||
"ADMIN_PRICING_SECTION_CORE_TITLE": "⚙️ Базовые лимиты",
|
||||
"ADMIN_PRICING_SECTION_CURRENT": "Текущие значения:",
|
||||
"ADMIN_PRICING_SECTION_EMPTY": "Нет доступных значений.",
|
||||
"ADMIN_PRICING_SECTION_EXTRA_TITLE": "➕ Дополнительные опции",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_PROMPT": "Нажмите на период, чтобы включить или выключить его отображение.",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_RENEW": "Активные периоды продления: {items}",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_SUB": "Активные периоды подписки: {items}",
|
||||
"ADMIN_PRICING_SECTION_PERIOD_OPTIONS_TITLE": "🗓 Доступные периоды",
|
||||
"ADMIN_PRICING_SECTION_PERIODS_TITLE": "🗓 Периоды подписки",
|
||||
"ADMIN_PRICING_SECTION_PROMPT": "Выберите что изменить:",
|
||||
"ADMIN_PRICING_SECTION_SETTINGS_GENERIC": "⚙️ Настройки",
|
||||
"ADMIN_PRICING_SECTION_TRAFFIC_TITLE": "📦 Пакеты трафика",
|
||||
"ADMIN_PRICING_SECTION_TRIAL_TITLE": "🎁 Пробный период",
|
||||
"ADMIN_PRICING_SETTING_CANCEL_HINT": "Чтобы вернуться без изменений, ответьте «Отмена».",
|
||||
"ADMIN_PRICING_SETTING_CURRENT": "Текущее значение",
|
||||
"ADMIN_PRICING_SETTING_EDIT_TITLE": "Настройка параметра",
|
||||
"ADMIN_PRICING_SETTING_EDIT_WITH_VALUE": "✏️ {label} • {value}",
|
||||
"ADMIN_PRICING_SETTING_EXAMPLE": "Пример",
|
||||
"ADMIN_PRICING_SETTING_FORMAT": "Формат ввода",
|
||||
"ADMIN_PRICING_SETTING_INVALID": "Не удалось обновить параметр. Проверьте формат значения.",
|
||||
"ADMIN_PRICING_SETTING_PARAMETER": "Параметр",
|
||||
"ADMIN_PRICING_SETTING_PROMPT": "Отправьте новое значение или напишите «Отмена». Для очистки используйте none.",
|
||||
"ADMIN_PRICING_SETTING_SUCCESS": "Параметр {label} обновлен: {value}",
|
||||
"ADMIN_PRICING_SETTING_TOGGLE_STATEFUL": "{icon} {label}",
|
||||
"ADMIN_PRICING_SETTING_WARNING": "Важно"
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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.broadcast_service import broadcast_service
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -46,12 +47,22 @@ async def main():
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
|
||||
|
||||
try:
|
||||
ensure_locale_templates()
|
||||
except Exception as error:
|
||||
logger.warning("Failed to prepare locale templates: %s", error)
|
||||
timeline = StartupTimeline(logger, "Bedolaga Remnawave Bot")
|
||||
timeline.log_banner(
|
||||
[
|
||||
("Уровень логирования", settings.LOG_LEVEL),
|
||||
("Режим БД", settings.DATABASE_MODE),
|
||||
]
|
||||
)
|
||||
|
||||
async with timeline.stage(
|
||||
"Подготовка локализаций", "🗂️", success_message="Шаблоны локализаций готовы"
|
||||
) as stage:
|
||||
try:
|
||||
ensure_locale_templates()
|
||||
except Exception as error:
|
||||
stage.warning(f"Не удалось подготовить шаблоны локализаций: {error}")
|
||||
logger.warning("Failed to prepare locale templates: %s", error)
|
||||
|
||||
killer = GracefulExit()
|
||||
signal.signal(signal.SIGINT, killer.exit_gracefully)
|
||||
@@ -66,168 +77,271 @@ async def main():
|
||||
polling_task = None
|
||||
web_api_server = None
|
||||
|
||||
try:
|
||||
logger.info("📊 Инициализация базы данных...")
|
||||
await init_db()
|
||||
|
||||
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
|
||||
|
||||
if not skip_migration:
|
||||
logger.info("🔧 Выполняем проверку и миграцию базы данных...")
|
||||
try:
|
||||
migration_success = await run_universal_migration()
|
||||
|
||||
if migration_success:
|
||||
logger.info("✅ Миграция базы данных завершена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск")
|
||||
|
||||
except Exception as migration_error:
|
||||
logger.error(f"❌ Ошибка выполнения миграции: {migration_error}")
|
||||
logger.warning("⚠️ Продолжаем запуск без миграции")
|
||||
else:
|
||||
logger.info("ℹ️ Миграция пропущена (SKIP_MIGRATION=true)")
|
||||
|
||||
logger.info("⚙️ Загрузка конфигурации из БД...")
|
||||
try:
|
||||
await bot_configuration_service.initialize()
|
||||
logger.info("✅ Конфигурация загружена")
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Не удалось загрузить конфигурацию: {error}")
|
||||
summary_logged = False
|
||||
|
||||
logger.info("🤖 Настройка бота...")
|
||||
bot, dp = await setup_bot()
|
||||
try:
|
||||
async with timeline.stage(
|
||||
"Инициализация базы данных", "🗄️", success_message="База данных готова"
|
||||
):
|
||||
await init_db()
|
||||
|
||||
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
|
||||
|
||||
if not skip_migration:
|
||||
async with timeline.stage(
|
||||
"Проверка и миграция базы данных",
|
||||
"🧬",
|
||||
success_message="Миграция завершена успешно",
|
||||
) as stage:
|
||||
try:
|
||||
migration_success = await run_universal_migration()
|
||||
if migration_success:
|
||||
stage.success("Миграция завершена успешно")
|
||||
else:
|
||||
stage.warning(
|
||||
"Миграция завершилась с предупреждениями, запуск продолжится"
|
||||
)
|
||||
logger.warning(
|
||||
"⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск"
|
||||
)
|
||||
except Exception as migration_error:
|
||||
stage.warning(f"Ошибка выполнения миграции: {migration_error}")
|
||||
logger.error(f"❌ Ошибка выполнения миграции: {migration_error}")
|
||||
logger.warning("⚠️ Продолжаем запуск без миграции")
|
||||
else:
|
||||
timeline.add_manual_step(
|
||||
"Проверка и миграция базы данных",
|
||||
"⏭️",
|
||||
"Пропущено",
|
||||
"SKIP_MIGRATION=true",
|
||||
)
|
||||
|
||||
async with timeline.stage(
|
||||
"Загрузка конфигурации из БД",
|
||||
"⚙️",
|
||||
success_message="Конфигурация загружена",
|
||||
) as stage:
|
||||
try:
|
||||
await bot_configuration_service.initialize()
|
||||
except Exception as error:
|
||||
stage.warning(f"Не удалось загрузить конфигурацию: {error}")
|
||||
logger.error(f"❌ Не удалось загрузить конфигурацию: {error}")
|
||||
|
||||
bot = None
|
||||
dp = None
|
||||
async with timeline.stage("Настройка бота", "🤖", success_message="Бот настроен") as stage:
|
||||
bot, dp = await setup_bot()
|
||||
stage.log("Кеш и FSM подготовлены")
|
||||
|
||||
monitoring_service.bot = bot
|
||||
maintenance_service.set_bot(bot)
|
||||
broadcast_service.set_bot(bot)
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
admin_notification_service = AdminNotificationService(bot)
|
||||
version_service.bot = bot
|
||||
version_service.set_notification_service(admin_notification_service)
|
||||
logger.info(f"📄 Сервис версий настроен для репозитория: {version_service.repo}")
|
||||
logger.info(f"📦 Текущая версия: {version_service.current_version}")
|
||||
|
||||
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
|
||||
|
||||
logger.info("🗄️ Инициализация сервиса бекапов...")
|
||||
try:
|
||||
backup_service.bot = bot
|
||||
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
if settings_obj.auto_backup_enabled:
|
||||
await backup_service.start_auto_backup()
|
||||
logger.info("✅ Автобекапы запущены")
|
||||
|
||||
logger.info("✅ Сервис бекапов инициализирован")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}")
|
||||
async with timeline.stage(
|
||||
"Интеграция сервисов",
|
||||
"🔗",
|
||||
success_message="Сервисы подключены",
|
||||
) as stage:
|
||||
admin_notification_service = AdminNotificationService(bot)
|
||||
version_service.bot = bot
|
||||
version_service.set_notification_service(admin_notification_service)
|
||||
stage.log(f"Репозиторий версий: {version_service.repo}")
|
||||
stage.log(f"Текущая версия: {version_service.current_version}")
|
||||
stage.success("Мониторинг, уведомления и рассылки подключены")
|
||||
|
||||
logger.info("📊 Инициализация сервиса отчетов...")
|
||||
try:
|
||||
reporting_service.set_bot(bot)
|
||||
await reporting_service.start()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}")
|
||||
async with timeline.stage(
|
||||
"Сервис бекапов",
|
||||
"🗄️",
|
||||
success_message="Сервис бекапов инициализирован",
|
||||
) as stage:
|
||||
try:
|
||||
backup_service.bot = bot
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
if settings_obj.auto_backup_enabled:
|
||||
await backup_service.start_auto_backup()
|
||||
stage.log(
|
||||
"Автобекапы включены: интервал "
|
||||
f"{settings_obj.backup_interval_hours}ч, запуск {settings_obj.backup_time}"
|
||||
)
|
||||
else:
|
||||
stage.log("Автобекапы отключены настройками")
|
||||
stage.success("Сервис бекапов инициализирован")
|
||||
except Exception as e:
|
||||
stage.warning(f"Ошибка инициализации сервиса бекапов: {e}")
|
||||
logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}")
|
||||
|
||||
async with timeline.stage(
|
||||
"Сервис отчетов",
|
||||
"📊",
|
||||
success_message="Сервис отчетов готов",
|
||||
) as stage:
|
||||
try:
|
||||
reporting_service.set_bot(bot)
|
||||
await reporting_service.start()
|
||||
except Exception as e:
|
||||
stage.warning(f"Ошибка запуска сервиса отчетов: {e}")
|
||||
logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}")
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
|
||||
webhook_needed = (
|
||||
settings.TRIBUTE_ENABLED
|
||||
or settings.is_cryptobot_enabled()
|
||||
or settings.is_mulenpay_enabled()
|
||||
)
|
||||
|
||||
if webhook_needed:
|
||||
enabled_services = []
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
enabled_services.append("Tribute")
|
||||
if settings.is_mulenpay_enabled():
|
||||
enabled_services.append("Mulen Pay")
|
||||
if settings.is_cryptobot_enabled():
|
||||
enabled_services.append("CryptoBot")
|
||||
|
||||
logger.info(f"🌐 Запуск webhook сервера для: {', '.join(enabled_services)}...")
|
||||
webhook_server = WebhookServer(bot)
|
||||
await webhook_server.start()
|
||||
else:
|
||||
logger.info("ℹ️ Tribute и CryptoBot отключены, webhook сервер не запускается")
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info("💳 Запуск YooKassa webhook сервера...")
|
||||
yookassa_server_task = asyncio.create_task(
|
||||
start_yookassa_webhook_server(payment_service)
|
||||
)
|
||||
else:
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
logger.info("💳 Запуск PayPalych webhook сервера...")
|
||||
pal24_server = await start_pal24_webhook_server(payment_service)
|
||||
else:
|
||||
logger.info("ℹ️ PayPalych отключен, webhook сервер не запускается")
|
||||
async with timeline.stage(
|
||||
"Webhook сервисы",
|
||||
"🌐",
|
||||
success_message="Webhook сервера настроены",
|
||||
) as stage:
|
||||
if webhook_needed:
|
||||
enabled_services = []
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
enabled_services.append("Tribute")
|
||||
if settings.is_mulenpay_enabled():
|
||||
enabled_services.append("Mulen Pay")
|
||||
if settings.is_cryptobot_enabled():
|
||||
enabled_services.append("CryptoBot")
|
||||
|
||||
logger.info("📊 Запуск службы мониторинга...")
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
logger.info("🔧 Проверка службы техработ...")
|
||||
if not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
logger.info("🔧 Запуск службы техработ...")
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
else:
|
||||
logger.info("🔧 Служба техработ уже запущена")
|
||||
maintenance_task = None
|
||||
|
||||
if settings.is_version_check_enabled():
|
||||
logger.info("📄 Запуск сервиса проверки версий...")
|
||||
version_check_task = asyncio.create_task(version_service.start_periodic_check())
|
||||
else:
|
||||
logger.info("ℹ️ Проверка версий отключена")
|
||||
|
||||
if settings.is_web_api_enabled():
|
||||
try:
|
||||
from app.webapi import WebAPIServer
|
||||
webhook_server = WebhookServer(bot)
|
||||
await webhook_server.start()
|
||||
stage.log(f"Активированы: {', '.join(enabled_services)}")
|
||||
stage.success("Webhook сервера запущены")
|
||||
else:
|
||||
stage.skip("Tribute, Mulen Pay и CryptoBot отключены")
|
||||
|
||||
web_api_server = WebAPIServer()
|
||||
await web_api_server.start()
|
||||
logger.info(
|
||||
"🌐 Административное веб-API запущено: http://%s:%s",
|
||||
settings.WEB_API_HOST,
|
||||
settings.WEB_API_PORT,
|
||||
async with timeline.stage(
|
||||
"YooKassa webhook",
|
||||
"💳",
|
||||
success_message="YooKassa webhook запущен",
|
||||
) as stage:
|
||||
if settings.is_yookassa_enabled():
|
||||
yookassa_server_task = asyncio.create_task(
|
||||
start_yookassa_webhook_server(payment_service)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Не удалось запустить веб-API: {error}")
|
||||
else:
|
||||
logger.info("ℹ️ Веб-API отключено")
|
||||
stage.log(
|
||||
f"Endpoint: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}"
|
||||
)
|
||||
else:
|
||||
stage.skip("YooKassa отключена настройками")
|
||||
|
||||
logger.info("📄 Запуск polling...")
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("🎯 Активные webhook endpoints:")
|
||||
async with timeline.stage(
|
||||
"PayPalych webhook",
|
||||
"💳",
|
||||
success_message="PayPalych webhook запущен",
|
||||
) as stage:
|
||||
if settings.is_pal24_enabled():
|
||||
pal24_server = await start_pal24_webhook_server(payment_service)
|
||||
stage.log(
|
||||
f"Endpoint: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
|
||||
)
|
||||
else:
|
||||
stage.skip("PayPalych отключен настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"Служба мониторинга",
|
||||
"📈",
|
||||
success_message="Служба мониторинга запущена",
|
||||
) as stage:
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
stage.log(f"Интервал опроса: {settings.MONITORING_INTERVAL}с")
|
||||
|
||||
async with timeline.stage(
|
||||
"Служба техработ",
|
||||
"🛡️",
|
||||
success_message="Служба техработ запущена",
|
||||
) as stage:
|
||||
if not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
stage.log(f"Интервал проверки: {settings.MAINTENANCE_CHECK_INTERVAL}с")
|
||||
else:
|
||||
maintenance_task = None
|
||||
stage.skip("Служба техработ уже активна")
|
||||
|
||||
async with timeline.stage(
|
||||
"Сервис проверки версий",
|
||||
"📄",
|
||||
success_message="Проверка версий запущена",
|
||||
) as stage:
|
||||
if settings.is_version_check_enabled():
|
||||
version_check_task = asyncio.create_task(version_service.start_periodic_check())
|
||||
stage.log(
|
||||
f"Интервал проверки: {settings.VERSION_CHECK_INTERVAL_HOURS}ч"
|
||||
)
|
||||
else:
|
||||
version_check_task = None
|
||||
stage.skip("Проверка версий отключена настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"Административное веб-API",
|
||||
"🌐",
|
||||
success_message="Веб-API запущено",
|
||||
) as stage:
|
||||
if settings.is_web_api_enabled():
|
||||
try:
|
||||
from app.webapi import WebAPIServer
|
||||
|
||||
web_api_server = WebAPIServer()
|
||||
await web_api_server.start()
|
||||
stage.success(
|
||||
f"Доступно на http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}"
|
||||
)
|
||||
except Exception as error:
|
||||
stage.warning(f"Не удалось запустить веб-API: {error}")
|
||||
logger.error(f"❌ Не удалось запустить веб-API: {error}")
|
||||
else:
|
||||
stage.skip("Веб-API отключено")
|
||||
|
||||
async with timeline.stage(
|
||||
"Запуск polling",
|
||||
"🤖",
|
||||
success_message="Aiogram polling запущен",
|
||||
) as stage:
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
|
||||
stage.log("skip_updates=True")
|
||||
|
||||
webhook_lines = []
|
||||
if webhook_needed:
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
webhook_lines.append(
|
||||
f"Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
logger.info(f" Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}")
|
||||
webhook_lines.append(
|
||||
f"Mulen Pay: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
webhook_lines.append(
|
||||
f"CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}"
|
||||
)
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
if settings.is_pal24_enabled():
|
||||
logger.info(
|
||||
f" PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
|
||||
webhook_lines.append(
|
||||
f"YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}"
|
||||
)
|
||||
logger.info("📄 Активные фоновые сервисы:")
|
||||
logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}")
|
||||
logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}")
|
||||
logger.info(f" Проверка версий: {'Включен' if version_check_task else 'Отключен'}")
|
||||
logger.info(
|
||||
" Отчеты: %s",
|
||||
"Включен" if reporting_service.is_running() else "Отключен",
|
||||
if settings.is_pal24_enabled():
|
||||
webhook_lines.append(
|
||||
f"PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
|
||||
)
|
||||
|
||||
timeline.log_section(
|
||||
"Активные webhook endpoints",
|
||||
webhook_lines if webhook_lines else ["Нет активных endpoints"],
|
||||
icon="🎯",
|
||||
)
|
||||
logger.info("=" * 50)
|
||||
|
||||
services_lines = [
|
||||
f"Мониторинг: {'Включен' if monitoring_task else 'Отключен'}",
|
||||
f"Техработы: {'Включен' if maintenance_task else 'Отключен'}",
|
||||
f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}",
|
||||
f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}",
|
||||
]
|
||||
timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄")
|
||||
|
||||
timeline.log_summary()
|
||||
summary_logged = True
|
||||
|
||||
try:
|
||||
while not killer.exit:
|
||||
@@ -276,6 +390,9 @@ async def main():
|
||||
raise
|
||||
|
||||
finally:
|
||||
if not summary_logged:
|
||||
timeline.log_summary()
|
||||
summary_logged = True
|
||||
logger.info("🛑 Начинается корректное завершение работы...")
|
||||
|
||||
if yookassa_server_task and not yookassa_server_task.done():
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"config": {},
|
||||
"platforms": {
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"fa": "App Store [EU]",
|
||||
"ru": "App Store [EU]",
|
||||
"zh": "App Store [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"fa": "App Store [RU]",
|
||||
"ru": "App Store [RU]",
|
||||
"zh": "App Store [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"fa": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Google Play"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
|
||||
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"fa": "برنامه را باز کنید و به سرور متصل شوید",
|
||||
"ru": "Откройте приложение и подключитесь к серверу",
|
||||
"zh": "打开应用并连接到服务器"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"macos": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"fa": "App Store [EU]",
|
||||
"ru": "App Store [EU]",
|
||||
"zh": "App Store [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"fa": "App Store [RU]",
|
||||
"ru": "App Store [RU]",
|
||||
"zh": "App Store [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
|
||||
"buttonText": {
|
||||
"en": "Download",
|
||||
"ru": "Скачать"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Download and install Happ.",
|
||||
"ru": "Скачайте и установите Happ"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server",
|
||||
"ru": "Выберете локацию, включить VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"androidTV": [
|
||||
{
|
||||
"id": "vpn4tv",
|
||||
"name": "VPN4TV",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linux": [],
|
||||
"appleTV": []
|
||||
}
|
||||
}
|
||||
@@ -1648,6 +1648,7 @@
|
||||
en: {
|
||||
'app.title': 'VPN Subscription',
|
||||
'app.name': 'VPN',
|
||||
'values.not_available': 'Not available',
|
||||
'app.subtitle': 'Secure & Fast Connection',
|
||||
'app.loading': 'Loading your subscription...',
|
||||
'error.default.title': 'Subscription Not Found',
|
||||
@@ -1711,6 +1712,7 @@
|
||||
ru: {
|
||||
'app.title': 'Подписка VPN',
|
||||
'app.name': 'VPN',
|
||||
'values.not_available': 'Недоступно',
|
||||
'app.subtitle': 'Безопасное и быстрое подключение',
|
||||
'app.loading': 'Загружаем вашу подписку...',
|
||||
'error.default.title': 'Подписка не найдена',
|
||||
@@ -1773,6 +1775,25 @@
|
||||
}
|
||||
};
|
||||
|
||||
const pcTranslations = {
|
||||
en: {
|
||||
'pc.happ.title': 'Open Subscription',
|
||||
'pc.happ.message': 'The Happ application doesn\'t appear to be installed on your PC. How would you like to proceed?',
|
||||
'pc.happ.copy': 'Copy subscription link',
|
||||
'pc.happ.redirect': 'Open in browser',
|
||||
},
|
||||
ru: {
|
||||
'pc.happ.title': 'Открыть подписку',
|
||||
'pc.happ.message': 'Приложение Happ не установлено на вашем ПК. Как вы хотите продолжить?',
|
||||
'pc.happ.copy': 'Скопировать ссылку подписки',
|
||||
'pc.happ.redirect': 'Открыть в браузере',
|
||||
}
|
||||
};
|
||||
|
||||
Object.keys(pcTranslations).forEach(lang => {
|
||||
Object.assign(translations[lang], pcTranslations[lang]);
|
||||
});
|
||||
|
||||
// Include all the original JavaScript functions
|
||||
function applyBrandingOverrides(branding) {
|
||||
if (!branding || typeof branding !== 'object') {
|
||||
@@ -2697,6 +2718,21 @@
|
||||
}
|
||||
|
||||
const happCryptoLink = getHappCryptoLink();
|
||||
const isPC = currentPlatform === 'pc' || (!['ios', 'android', 'tv'].includes(currentPlatform));
|
||||
|
||||
// For PC, prefer direct subscription URL if no Happ redirect link
|
||||
if (isPC) {
|
||||
if (userData.happ_cryptolink_redirect_link) {
|
||||
return userData.happ_cryptolink_redirect_link;
|
||||
}
|
||||
if (happCryptoLink) {
|
||||
return happCryptoLink;
|
||||
}
|
||||
// Return plain subscription URL for PC
|
||||
return getCurrentSubscriptionUrl();
|
||||
}
|
||||
|
||||
// Original logic for mobile platforms
|
||||
if (happCryptoLink) {
|
||||
return happCryptoLink;
|
||||
}
|
||||
@@ -2729,6 +2765,16 @@
|
||||
|
||||
const { openInMiniApp = false } = options;
|
||||
|
||||
// Detect if we're on PC and trying to open a happ:// link
|
||||
const isPC = currentPlatform === 'pc' || (!['ios', 'android', 'tv'].includes(currentPlatform));
|
||||
const isHappLink = link.startsWith('happ://');
|
||||
|
||||
if (isPC && isHappLink) {
|
||||
// For PC users with happ:// links, provide fallback options
|
||||
handlePCHappLink(link);
|
||||
return;
|
||||
}
|
||||
|
||||
if (openInMiniApp) {
|
||||
window.location.href = link;
|
||||
return;
|
||||
@@ -2752,6 +2798,118 @@
|
||||
window.location.href = link;
|
||||
}
|
||||
|
||||
function handlePCHappLink(happLink) {
|
||||
// Extract the subscription URL from the happ:// link
|
||||
const subscriptionUrl = happLink.replace('happ://', '');
|
||||
|
||||
// Try to open the happ:// link first (in case user has the app installed)
|
||||
const testWindow = window.open(happLink, '_blank');
|
||||
|
||||
// Set a timeout to check if the link opened successfully
|
||||
setTimeout(() => {
|
||||
// If the window is still open and accessible, the app likely isn't installed
|
||||
if (testWindow && !testWindow.closed) {
|
||||
testWindow.close();
|
||||
|
||||
// Show options to the user
|
||||
if (typeof tg.showPopup === 'function') {
|
||||
tg.showPopup({
|
||||
title: t('pc.happ.title') || 'Open Subscription',
|
||||
message: t('pc.happ.message') || 'The Happ application doesn\'t appear to be installed. Would you like to:',
|
||||
buttons: [
|
||||
{
|
||||
id: 'copy',
|
||||
type: 'default',
|
||||
text: t('pc.happ.copy') || 'Copy subscription link'
|
||||
},
|
||||
{
|
||||
id: 'redirect',
|
||||
type: 'default',
|
||||
text: t('pc.happ.redirect') || 'Open in browser'
|
||||
},
|
||||
{
|
||||
type: 'cancel'
|
||||
}
|
||||
]
|
||||
}, (buttonId) => {
|
||||
if (buttonId === 'copy') {
|
||||
copySubscriptionUrl(subscriptionUrl);
|
||||
} else if (buttonId === 'redirect') {
|
||||
openWithRedirect(subscriptionUrl);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback for environments without showPopup
|
||||
const choice = confirm(
|
||||
'The Happ application doesn\'t appear to be installed.\n\n' +
|
||||
'Click OK to open the subscription in your browser, or Cancel to copy the link.'
|
||||
);
|
||||
if (choice) {
|
||||
openWithRedirect(subscriptionUrl);
|
||||
} else {
|
||||
copySubscriptionUrl(subscriptionUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1500); // Wait 1.5 seconds to see if the app opens
|
||||
}
|
||||
|
||||
// Add function to open with redirect parameter
|
||||
function openWithRedirect(subscriptionUrl) {
|
||||
// Create the redirect URL
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUrl = `${currentOrigin}/index.html?redirect_to=${encodeURIComponent(subscriptionUrl)}`;
|
||||
|
||||
// Open in a new tab/window
|
||||
const newWindow = window.open(redirectUrl, '_blank');
|
||||
if (newWindow) {
|
||||
newWindow.opener = null;
|
||||
} else {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function copySubscriptionUrl(url) {
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showPopup(
|
||||
t('notifications.copy.success') || 'Subscription link copied to clipboard.',
|
||||
t('notifications.copy.title.success') || 'Copied'
|
||||
);
|
||||
} else {
|
||||
// Fallback for non-secure contexts
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = url;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showPopup(
|
||||
t('notifications.copy.success') || 'Subscription link copied to clipboard.',
|
||||
t('notifications.copy.title.success') || 'Copied'
|
||||
);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Clipboard copy failed:', error);
|
||||
showPopup(
|
||||
t('notifications.copy.failure') || 'Unable to copy. Please copy manually: ' + url,
|
||||
t('notifications.copy.title.failure') || 'Copy failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function updateActionButtons() {
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#2481cc">
|
||||
<title>Connecting to VPN...</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--tg-theme-bg-color: #ffffff;
|
||||
--tg-theme-text-color: #000000;
|
||||
--tg-theme-hint-color: #999999;
|
||||
--tg-theme-link-color: #2481cc;
|
||||
--tg-theme-button-color: #2481cc;
|
||||
--tg-theme-button-text-color: #ffffff;
|
||||
--tg-theme-secondary-bg-color: #f0f0f0;
|
||||
|
||||
--primary: var(--tg-theme-button-color);
|
||||
--primary-rgb: 36, 129, 204;
|
||||
--text-primary: var(--tg-theme-text-color);
|
||||
--text-secondary: var(--tg-theme-hint-color);
|
||||
--bg-primary: var(--tg-theme-bg-color);
|
||||
--bg-secondary: var(--tg-theme-secondary-bg-color);
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--info: #3b82f6;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: rgba(30, 41, 59, 0.85);
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--border-color: rgba(148, 163, 184, 0.25);
|
||||
--shadow-sm: 0 2px 8px rgba(2, 6, 23, 0.3);
|
||||
--shadow-md: 0 4px 16px rgba(2, 6, 23, 0.45);
|
||||
--shadow-lg: 0 8px 32px rgba(2, 6, 23, 0.55);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Language Switcher */
|
||||
.top-bar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.language-select {
|
||||
padding: 8px 32px 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.language-select:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 16px;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.logo-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transform: rotate(45deg);
|
||||
animation: shimmer 3s infinite;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 40px;
|
||||
color: white;
|
||||
z-index: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Main Card */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.card.error {
|
||||
border: 2px solid var(--danger);
|
||||
background: linear-gradient(135deg, var(--bg-secondary), rgba(239, 68, 68, 0.05));
|
||||
}
|
||||
|
||||
.card.error .logo-container {
|
||||
background: linear-gradient(135deg, var(--danger), rgba(239, 68, 68, 0.7));
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid var(--bg-secondary);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Timer */
|
||||
.timer-container {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
border: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.timer-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.timer-number {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timer-unit {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary), rgba(var(--primary-rgb), 0.6));
|
||||
border-radius: 3px;
|
||||
transition: width 1s linear;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Status Text */
|
||||
.status-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8));
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* Hidden */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.timer-number {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Adjustments */
|
||||
:root[data-theme="dark"] .timer-container {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .btn-primary {
|
||||
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.45);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top Bar -->
|
||||
<div class="top-bar">
|
||||
<select id="languageSelect" class="language-select">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
</select>
|
||||
<button class="theme-toggle" id="themeToggle">
|
||||
<svg class="icon-sun" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 18a6 6 0 100-12 6 6 0 000 12zM12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
<svg class="icon-moon" width="20" height="20" fill="currentColor" viewBox="0 0 24 24" style="display: none;">
|
||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header animate-in">
|
||||
<div class="logo-container">
|
||||
<div class="logo-icon" id="appIcon">⚡</div>
|
||||
</div>
|
||||
<div class="logo" data-i18n="app.name">VPN</div>
|
||||
<div class="subtitle" id="appSubtitle" data-i18n="app.connecting">Connecting to VPN...</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Card -->
|
||||
<div class="card animate-in" id="mainCard">
|
||||
<div class="loading" id="loadingState">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text" id="statusText" data-i18n="status.connecting">Establishing secure connection...</div>
|
||||
|
||||
<div class="timer-container" id="timerContainer">
|
||||
<div class="timer-label" data-i18n="timer.label">Redirecting in</div>
|
||||
<div class="timer-display">
|
||||
<span class="timer-number" id="timer">10</span>
|
||||
<span class="timer-unit" data-i18n="timer.seconds">seconds</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status-description" data-i18n="status.manual">If nothing happens, click the button below.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State (hidden by default) -->
|
||||
<div class="error-state hidden" id="errorState">
|
||||
<div class="status-text error-text" data-i18n="error.title">Connection Error</div>
|
||||
<p class="status-description" data-i18n="error.description">The connection link is missing or invalid.</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<a class="btn btn-primary" id="actionButton" href="#" target="_blank" rel="noopener">
|
||||
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span data-i18n="button.connect">Connect to VPN</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// App schemes configuration
|
||||
const appSchemes = [
|
||||
{ scheme: 'happ://', id: 'happ', icon: 'H', name: { en: 'Happ', ru: 'Happ' } },
|
||||
{ scheme: 'flclash://', id: 'flclash', icon: 'F', name: { en: 'FlClash', ru: 'FlClash' } },
|
||||
{ scheme: 'clash://', id: 'clash-meta', icon: 'C', name: { en: 'Clash Meta', ru: 'Clash Meta' } },
|
||||
{ scheme: 'sing-box://', id: 'sing-box', icon: 'S', name: { en: 'Sing-box', ru: 'Sing-box' } },
|
||||
{ scheme: 'v2rayng://', id: 'v2rayng', icon: 'V', name: { en: 'v2rayNG', ru: 'v2rayNG' } },
|
||||
{ scheme: 'sub://', id: 'shadowrocket', icon: 'R', name: { en: 'Shadowrocket', ru: 'Shadowrocket' } },
|
||||
{ scheme: 'hiddify://', id: 'hiddify', icon: 'H', name: { en: 'Hiddify', ru: 'Hiddify' } }
|
||||
];
|
||||
|
||||
// Translations
|
||||
const translations = {
|
||||
en: {
|
||||
'app.name': 'VPN',
|
||||
'app.connecting': 'Connecting to VPN...',
|
||||
'status.connecting': 'Establishing secure connection...',
|
||||
'timer.label': 'Redirecting in',
|
||||
'timer.seconds': 'seconds',
|
||||
'status.manual': 'If nothing happens, click the button below.',
|
||||
'button.connect': 'Connect to VPN',
|
||||
'error.title': 'Connection Error',
|
||||
'error.description': 'The connection link is missing or invalid. Please contact support.'
|
||||
},
|
||||
ru: {
|
||||
'app.name': 'VPN',
|
||||
'app.connecting': 'Подключение к VPN...',
|
||||
'status.connecting': 'Установка безопасного соединения...',
|
||||
'timer.label': 'Перенаправление через',
|
||||
'timer.seconds': 'секунд',
|
||||
'status.manual': 'Если ничего не происходит, нажмите кнопку ниже.',
|
||||
'button.connect': 'Подключиться к VPN',
|
||||
'error.title': 'Ошибка подключения',
|
||||
'error.description': 'Ссылка для подключения отсутствует или недействительна. Обратитесь в поддержку.'
|
||||
}
|
||||
};
|
||||
|
||||
// Global variables
|
||||
let currentLanguage = 'en';
|
||||
let currentTheme = 'light';
|
||||
let timerValue = 10;
|
||||
let timerId = null;
|
||||
let redirectTo = '';
|
||||
let appInfo = null;
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSettings();
|
||||
|
||||
// Get redirect URL from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
redirectTo = urlParams.get('redirect_to') || '';
|
||||
|
||||
// Detect app from URL scheme
|
||||
if (redirectTo) {
|
||||
appInfo = appSchemes.find(a => redirectTo.startsWith(a.scheme));
|
||||
}
|
||||
|
||||
updateUI();
|
||||
|
||||
if (redirectTo) {
|
||||
startTimer();
|
||||
} else {
|
||||
showError();
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved settings
|
||||
function loadSettings() {
|
||||
const savedTheme = localStorage.getItem('remnawave-miniapp-theme') || 'light';
|
||||
const savedLang = localStorage.getItem('remnawave-miniapp-language') || getBrowserLanguage();
|
||||
|
||||
currentTheme = savedTheme;
|
||||
currentLanguage = savedLang;
|
||||
|
||||
applyTheme(savedTheme);
|
||||
document.getElementById('languageSelect').value = currentLanguage;
|
||||
}
|
||||
|
||||
// Get browser language
|
||||
function getBrowserLanguage() {
|
||||
const lang = navigator.language || navigator.userLanguage;
|
||||
if (lang.startsWith('ru')) return 'ru';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
updateThemeToggle();
|
||||
}
|
||||
|
||||
// Update theme toggle icon
|
||||
function updateThemeToggle() {
|
||||
const sunIcon = document.querySelector('.icon-sun');
|
||||
const moonIcon = document.querySelector('.icon-moon');
|
||||
|
||||
if (currentTheme === 'dark') {
|
||||
sunIcon.style.display = 'none';
|
||||
moonIcon.style.display = 'block';
|
||||
} else {
|
||||
sunIcon.style.display = 'block';
|
||||
moonIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle theme
|
||||
document.getElementById('themeToggle').addEventListener('click', function() {
|
||||
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('remnawave-miniapp-theme', currentTheme);
|
||||
applyTheme(currentTheme);
|
||||
});
|
||||
|
||||
// Change language
|
||||
document.getElementById('languageSelect').addEventListener('change', function(e) {
|
||||
currentLanguage = e.target.value;
|
||||
localStorage.setItem('remnawave-miniapp-language', currentLanguage);
|
||||
updateUI();
|
||||
});
|
||||
|
||||
// Get translation
|
||||
function t(key) {
|
||||
return translations[currentLanguage]?.[key] || translations.en[key] || key;
|
||||
}
|
||||
|
||||
// Update UI with translations
|
||||
function updateUI() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
|
||||
// Update app-specific info
|
||||
if (appInfo && redirectTo) {
|
||||
const appName = appInfo.name[currentLanguage] || appInfo.name.en;
|
||||
document.getElementById('appIcon').textContent = appInfo.icon;
|
||||
document.getElementById('appSubtitle').textContent = `${t('app.connecting').replace('VPN', appName)}`;
|
||||
|
||||
const statusText = document.getElementById('statusText');
|
||||
if (statusText) {
|
||||
statusText.textContent = `${t('status.connecting').replace('...', '')} ${appName}...`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update action button
|
||||
const actionButton = document.getElementById('actionButton');
|
||||
if (actionButton && redirectTo) {
|
||||
actionButton.href = redirectTo;
|
||||
}
|
||||
}
|
||||
|
||||
// Start countdown timer
|
||||
function startTimer() {
|
||||
if (!redirectTo || timerId) return;
|
||||
|
||||
const timerEl = document.getElementById('timer');
|
||||
const progressEl = document.getElementById('progressFill');
|
||||
const totalTime = 10;
|
||||
|
||||
timerEl.textContent = timerValue;
|
||||
|
||||
timerId = setInterval(() => {
|
||||
timerValue--;
|
||||
timerEl.textContent = timerValue;
|
||||
|
||||
// Update progress bar
|
||||
const progress = ((totalTime - timerValue) / totalTime) * 100;
|
||||
progressEl.style.width = `${100 - progress}%`;
|
||||
|
||||
if (timerValue <= 0) {
|
||||
clearInterval(timerId);
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
function showError() {
|
||||
document.getElementById('loadingState').classList.add('hidden');
|
||||
document.getElementById('errorState').classList.remove('hidden');
|
||||
document.getElementById('actionButton').classList.add('hidden');
|
||||
document.getElementById('mainCard').classList.add('error');
|
||||
document.getElementById('appIcon').textContent = '!';
|
||||
|
||||
// Update subtitle
|
||||
document.getElementById('appSubtitle').textContent = t('error.title');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user