Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 155482779e | |||
| d1bacf140c | |||
| 55a38a5e05 | |||
| f21d451c13 | |||
| 29c3e00c27 | |||
| 44d249ad5b | |||
| 4333412169 | |||
| 50c7eaa29a | |||
| b2e168dbec | |||
| a1d4e9c7b3 | |||
| 82d1b48314 | |||
| eaffaa11b7 | |||
| 2c13995625 | |||
| 7b0892e379 | |||
| 483ccab3f7 | |||
| 7f62af6955 | |||
| 857dfc7e27 | |||
| f47ca40974 | |||
| 0256203f44 | |||
| 5bda94794e | |||
| 0d218980f7 | |||
| 73d8c30d9c | |||
| 7abafbeaa1 | |||
| 2a7728a76c | |||
| d266997720 | |||
| d6fb26d425 | |||
| 1eb3658ce5 | |||
| badf679bc3 | |||
| 1e542a0aa1 | |||
| cdbeca245f | |||
| 5a75626625 | |||
| 17e3985671 | |||
| 5a5f151b38 | |||
| 97c8ddf4e1 | |||
| df7414b670 | |||
| 8dfe2f2934 | |||
| 3f3968dda2 | |||
| 3930564f9b | |||
| 90edc1dc49 | |||
| c9f7906e10 | |||
| 1758eabe0e | |||
| 7ec73c9bc7 | |||
| 1b0ad8f8b6 | |||
| 555f76a4ec | |||
| bfb9c5d09c | |||
| 9a72a7e3e7 | |||
| b48c3f1235 | |||
| 85f59566a6 | |||
| 1c3f2c7500 | |||
| 47ca096e48 | |||
| a9da2a0e63 | |||
| aaea98049e | |||
| 53c7df0b8e | |||
| 86e8ac39c3 | |||
| a9a10e3e90 | |||
| 1de81a6aa4 | |||
| 540a0e35a1 | |||
| f0ae702933 | |||
| 84ed1fc5ca | |||
| de38f8219c | |||
| 12061f0752 | |||
| a5174f10d5 | |||
| 69eeaf6c46 | |||
| ac25e1b4c6 | |||
| 26e0800d70 | |||
| d0197aacd7 | |||
| b097c5db44 | |||
| 302f64ad5c | |||
| 23cd2097da | |||
| 58257a6eac | |||
| ea7b982b7b | |||
| 1fcd76db9e | |||
| 66d92cfa51 | |||
| 9dc5ee22ee | |||
| b5df729d97 | |||
| 56e3541b80 | |||
| 9681bce540 | |||
| 7330c729e0 | |||
| 977a19a4c9 | |||
| d226fedf75 | |||
| c9ee1da728 | |||
| d6753501e7 | |||
| c3f62a4ff6 | |||
| 48101eee39 | |||
| 16e771f1c3 | |||
| 0aa49a6a94 | |||
| c810619a13 | |||
| 817d277091 | |||
| d3a822e8b3 | |||
| ec8c37744a | |||
| 732c36630c | |||
| 498d2f04b6 | |||
| d167147725 | |||
| 19800971d6 | |||
| 4b457240b9 | |||
| b63c5867d4 | |||
| 598dd8b6c1 | |||
| 6cd399cea7 | |||
| 466033d04c | |||
| 99bca0220b | |||
| a10b9f0ad7 | |||
| 825d549822 | |||
| 03cb06e757 | |||
| 113b33e68d | |||
| f3a327c9c7 | |||
| 2ca4a989e2 | |||
| 7339f2cd4f | |||
| 827897b771 | |||
| eb5064d591 | |||
| 177862f178 | |||
| 7276dcf1e0 | |||
| 7587aec913 | |||
| 7186c93078 | |||
| 727e20584f | |||
| 9c13775eec | |||
| e767ef7d92 | |||
| ce22df4d60 | |||
| 7c4567ae70 | |||
| af1125f6ed | |||
| ee65e65108 | |||
| 5c1f7d54ba | |||
| 21e5af9e9e | |||
| f7d674adf4 | |||
| 5a7de9731a | |||
| f987dc2716 | |||
| bbdc1274e4 | |||
| feb95fe105 | |||
| 91b0d3782a | |||
| 6d613cab85 | |||
| a0b6aa13a3 | |||
| a794f5da70 | |||
| e571edb354 | |||
| 84dece39a7 | |||
| f49bf21caa | |||
| 64da0ef94f | |||
| 3177b5ab80 | |||
| ec1b459689 | |||
| 08f7f87035 | |||
| 59189cae2a | |||
| 573dcd034a | |||
| 0f29a8a33b | |||
| 98ec7e4165 | |||
| 46c7d53f6e | |||
| 61758b8c2a | |||
| 42ac9555ac | |||
| f7416f2255 | |||
| 509c92217f | |||
| c0d5ae1e9e | |||
| cf9fbc5ccc | |||
| ad19be83d2 | |||
| 3dd5e41bda | |||
| 639a45611e | |||
| 773299c317 | |||
| ff39b10761 | |||
| 5fd5472fd3 | |||
| adf94802b0 | |||
| 15378a412f | |||
| 32da16f652 | |||
| 075d92e4bd | |||
| a7cbcd284c | |||
| 5682d508a3 | |||
| c738b5703f | |||
| 6875fce2cb | |||
| 57b7ad6732 | |||
| 8377db323b | |||
| 78d768ea4d | |||
| 9dee654eee | |||
| a02f49916a | |||
| 8d25e9b760 | |||
| 62ebbb2b5f | |||
| e62caa725f | |||
| db5f81e6be | |||
| 15cba3da22 | |||
| ca93182f68 | |||
| b1a2b6774d | |||
| ebc98099d3 | |||
| e2137f94f8 | |||
| 3fdc081548 | |||
| 57cc2d2e3a | |||
| b883d2a95c | |||
| cfcb35c7ee | |||
| 7f4899b885 | |||
| 719d656a64 | |||
| 04476ed463 | |||
| efd8b191ff | |||
| 8273a31512 | |||
| 7baebd85c7 | |||
| e43f6afd8a | |||
| ec3430db27 | |||
| fe779a29b4 | |||
| b4a1d10037 | |||
| e2e3e444ec | |||
| 31c560093d |
+28
-2
@@ -5,13 +5,15 @@
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
@@ -33,6 +35,7 @@ POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# SQLite настройки (для локального запуска)
|
||||
SQLITE_PATH=./data/bot.db
|
||||
LOCALES_PATH=./locales
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
@@ -111,6 +114,11 @@ PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
@@ -259,6 +267,23 @@ NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
MONITORING_LOGS_RETENTION_DAYS=30
|
||||
NOTIFICATION_CACHE_HOURS=24
|
||||
|
||||
# ===== СТАТУС СЕРВЕРОВ =====
|
||||
# Режимы: disabled, external_link, xray
|
||||
SERVER_STATUS_MODE=disabled
|
||||
# Ссылка на внешний мониторинг (для режима external_link)
|
||||
SERVER_STATUS_EXTERNAL_URL=
|
||||
# URL метрик XrayChecker (для режима xray)
|
||||
SERVER_STATUS_METRICS_URL=
|
||||
# Данные Basic Auth (опционально)
|
||||
SERVER_STATUS_METRICS_USERNAME=
|
||||
SERVER_STATUS_METRICS_PASSWORD=
|
||||
# Проверять SSL сертификат при запросе метрик
|
||||
SERVER_STATUS_METRICS_VERIFY_SSL=true
|
||||
# Таймаут запроса к метрикам (в секундах)
|
||||
SERVER_STATUS_REQUEST_TIMEOUT=10
|
||||
# Количество серверов на странице в режиме интеграции
|
||||
SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
@@ -266,6 +291,7 @@ MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
|
||||
@@ -304,4 +330,4 @@ LOG_FILE=logs/bot.log
|
||||
# ===== РАЗРАБОТКА =====
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_PATH=/webhook
|
||||
@@ -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.3.5-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.8-$(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.3.5-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.8-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.3.5-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ steps.version.outputs.should_push }}
|
||||
tags: ${{ steps.version.outputs.tags }}
|
||||
build-args: |
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.5-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.8-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.5-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.8-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.5-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ steps.version.outputs.should_push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
!locales/
|
||||
!locales/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.3.5"
|
||||
ARG VERSION="v2.3.8"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers)
|
||||
|
||||
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💬 Поддержка](#-поддержка-и-сообщество)
|
||||
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,16 @@ docker compose logs
|
||||
| 🛡️ **REMNAWAVE_SECRET_KEY** | Ключ защиты панели | `secret_name:secret_value` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
|
||||
### 📊 Статус серверов в главном меню
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
|------------|----------|--------|
|
||||
| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link` (просто ссылка) или `xray` (интеграция с XrayChecker). | `xray` |
|
||||
| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг (используется в режиме `external_link`). | `https://status.example.com` |
|
||||
| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker (Prometheus формат). | `https://sub.example.com/metrics` |
|
||||
| `SERVER_STATUS_METRICS_USERNAME` / `SERVER_STATUS_METRICS_PASSWORD` | Данные Basic Auth, если страница метрик защищена паролем. | `status` / `secret` |
|
||||
| `SERVER_STATUS_ITEMS_PER_PAGE` | Количество серверов, показываемых на одной странице в режиме интеграции. | `10` |
|
||||
|
||||
### 🛡️ Защита панели Remnawave
|
||||
|
||||
Для панелей, защищенных через [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy):
|
||||
@@ -159,6 +169,12 @@ PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
@@ -229,6 +245,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
# Уведомления администраторов
|
||||
@@ -330,6 +347,11 @@ PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
@@ -478,6 +500,7 @@ MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
|
||||
@@ -536,35 +559,37 @@ WEBHOOK_PATH=/webhook
|
||||
🛒 **Умная покупка подписок**
|
||||
- 📅 Гибкие периоды (14-360 дней)
|
||||
- 📊 Выбор трафика или фиксированный лимит
|
||||
- 🌍 Автоматический выбор серверов(для одного сквада) или ручной выбор из множества
|
||||
- 📱 Настройка количества устройств
|
||||
- 🌍 Автоматический выбор серверов (для одного сквада) или ручной выбор из множества
|
||||
- 📱 Настройка количества устройств и серверов прямо при оформлении
|
||||
- 🧾 Понятный калькулятор стоимости с учетом всех опций
|
||||
|
||||
🧪 **Тестовая подписка**
|
||||
- Настраиваемый триал-период
|
||||
- Уведомления об истечении
|
||||
- Плавный переход на платную версию
|
||||
- Приветственное сообщение с предложением активации триала (С возможностью отключения)
|
||||
- Уведомления об истечении и сценарий мягкой конверсии в платную подписку
|
||||
- Приветственное сообщение с предложением активации триала (с возможностью отключения)
|
||||
- Поддержка рекламных кампаний с автоматической выдачей бонусных подписок
|
||||
|
||||
💰 **Удобные платежи**
|
||||
- ⭐ Telegram Stars
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa
|
||||
- 💰 CryptoBot
|
||||
- 🎁 Реферальные бонусы
|
||||
- Детальная история транзакций
|
||||
- ⭐ Telegram Stars
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa (включая СБП и онлайн-чек)
|
||||
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- Детальная история транзакций и чеков
|
||||
|
||||
📱 **Управление подписками**
|
||||
- 📈 Статистика использования в реальном времени (Используемый трафик, подключенные устройства)
|
||||
- 🔄 Автопродление с баланса
|
||||
- 🔄 Управление трафиком
|
||||
- 🌍 Переключение серверов на лету
|
||||
- 📈 Статистика использования в реальном времени (трафик, устройства, серверы)
|
||||
- 🔄 Автопродление с баланса с настройкой дней и мгновенными уведомлениями
|
||||
- 🔄 Управление трафиком и лимитами без участия оператора
|
||||
- 🌍 Переключение серверов Remnawave на лету
|
||||
- 📱 Управление устройствами + сброс HWID
|
||||
- 🧩 Смена языка и персональных настроек
|
||||
|
||||
🎁 **Бонусная система**
|
||||
- 🎫 Промокоды на деньги/дни/триал
|
||||
- 👥 Защищенная реферальная программа с комиссиями
|
||||
- 🔔 Своевременные уведомления
|
||||
- 📊 Детальная статистика рефералов
|
||||
- 🎫 Промокоды на деньги/дни/длинный триал
|
||||
- 👥 Защищенная реферальная программа с комиссиями и антифродом
|
||||
- 📊 Детальная статистика рефералов и кампаний
|
||||
- 📨 Персональные рассылки и уведомления о новых бонусах
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -572,63 +597,154 @@ WEBHOOK_PATH=/webhook
|
||||
### ⚙️ **Для администраторов**
|
||||
|
||||
📊 **Мощная аналитика**
|
||||
- 👥 Детальная статистика пользователей
|
||||
- 💰 Анализ подписок и платежей
|
||||
- 🖥️ Мониторинг серверов Remnawave
|
||||
- 📈 Финансовые отчеты и тренды
|
||||
- 👥 Детальная статистика пользователей и подписок
|
||||
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, CryptoBot)
|
||||
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
|
||||
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
|
||||
|
||||
👥 **Управление пользователями**
|
||||
- 🔍 Поиск и редактирование профилей
|
||||
- 💰 Управление балансами
|
||||
- 💰 Управление балансами и ручные начисления
|
||||
- 📱 Изменение лимита устройств (1-X)
|
||||
- 📊 Настройка лимитов трафика (0-10000 ГБ)
|
||||
- 🌍 Мультивыбор серверов
|
||||
- 🔄 Сброс HWID устройств
|
||||
- 📊 Настройка лимитов трафика (0-10000 ГБ) и подключенных сквадов
|
||||
- 🌍 Мультивыбор серверов и мгновенная синхронизация с панелью
|
||||
- 🔄 Сброс HWID устройств и обновление конфигураций
|
||||
- 👥 Детальная реферальная статистика в профиле пользователя
|
||||
- 🔄 **НОВОЕ**: Изминение типа подписки триал/платная
|
||||
- 🚫 Блокировка/разблокировка/удаление
|
||||
- 🔄 Изменение типа подписки триал/платная и ручное продление
|
||||
- 🚫 Блокировка/разблокировка/удаление аккаунтов
|
||||
|
||||
🎫 **Промо-система**
|
||||
- 🎁 Создание промокодов (деньги/дни/длинный триал)
|
||||
- 📊 Детальная статистика использования
|
||||
- ⚙️ Полное редактирование промокодов
|
||||
🎯 **Маркетинг и продажи**
|
||||
- 🎫 Гибкая промо-система (деньги/дни/длинный триал)
|
||||
- 📣 Рекламные кампании с автоматическими бонусами за регистрацию
|
||||
- 📊 Детальная статистика использования промокодов и кампаний
|
||||
- 📨 Рассылки по сегментам с фильтрами по активности, подпискам и языкам
|
||||
|
||||
🖥️ **Умный мониторинг**
|
||||
- 💚 Состояние Remnawave панели в реальном времени
|
||||
- 🔄 Автоматическая синхронизация данных
|
||||
- 🌐 Управление сквадами с актуальным статусом
|
||||
- 🚧 **Автоматический режим тех. работ**
|
||||
- 📋 Логи и диагностика
|
||||
- 💚 Контроль доступности Remnawave панели в реальном времени
|
||||
- 🔄 Автоматическая синхронизация данных и реконнект при ошибках
|
||||
- 🌐 Управление сквадами с актуальным статусом и массовыми действиями
|
||||
- 🚧 Автоматический режим тех. работ с уведомлениями пользователей
|
||||
- 📋 Логи и диагностика, включая журнал сбоев и действий бота
|
||||
- 📦 Проверка обновлений проекта и уведомления о новых релизах
|
||||
|
||||
📨 **Коммуникации**
|
||||
- 📢 Рассылки по сегментам
|
||||
- 🔔 Автоуведомления о продлении
|
||||
- 💬 Система поддержки с HTML разметкой
|
||||
- 📝 Настройка правил сервиса
|
||||
📨 **Коммуникации и поддержка**
|
||||
- 📢 Рассылки по сегментам и мгновенные оповещения
|
||||
- 🔔 Автоуведомления о продлении, задолженностях и автоплатежах
|
||||
- 💬 Система поддержки с HTML разметкой и история обращений
|
||||
- 📝 Настройка правил сервиса, FAQ и приветственных экранов
|
||||
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
|
||||
|
||||
📨 **Уведомления в закрытый канал**
|
||||
- 🎯 Активация триала
|
||||
- 💎 Покупка подписки
|
||||
- 🔄 Конверсия из триала в платную
|
||||
- ⏰ Продление подписки
|
||||
- 💰 Пополнение баланса
|
||||
- ♻️ Выход обновлений бота
|
||||
- 🚧 Потеря соелинения с апи Remnawave
|
||||
- 🗄️ **Бекапы/Восстановление бд**
|
||||
- 🗄️ Отправка бд файлов в отдельный чат/топики
|
||||
- ⚙️ Изменение параметров подписки юзером (Уменьшение/Увеличение трафика/серверов/устройств на подписке)
|
||||
- 🎯 Активация триала, продления и конверсии в платную подписку
|
||||
- 💎 Покупка подписки и пополнение баланса
|
||||
- 🔄 Изменения параметров подписки пользователем
|
||||
- 🚧 Потеря соединения с Remnawave и включение техработ
|
||||
- 🗄️ Авто- и ручные бекапы/восстановления с отправкой файлов
|
||||
- ♻️ Выхлоп новых версий бота и результаты автоплатежей
|
||||
|
||||
🗄️ **Бекапы/Восстановление**
|
||||
- Ручной запуск бекапа
|
||||
- Восстановление бд
|
||||
- Включение/Отключение автобекапов
|
||||
- Отправка копии бд файла в отдеьный чат/топик
|
||||
🗄️ **Бекапы и восстановление**
|
||||
- Ручной запуск бекапа с выбором содержимого (включая логи)
|
||||
- Автоматические бекапы по расписанию и управление retention-политикой
|
||||
- Восстановление из бекапа без остановки сервиса
|
||||
- Отправка копии бекапа в выделенный чат/топик и ведение метаданных
|
||||
|
||||
💳 **Автоплатежи и биллинг**
|
||||
- Настройки автоплатежа на уровне пользователя (вкл/выкл, дни до списания)
|
||||
- Принудительная проверка готовности автосписаний
|
||||
- Уведомления об успешных и неудачных автоплатежах с подсказками действий
|
||||
- Настройка минимального баланса и логирование всех списаний
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🤖 Автоматизация и экосистема
|
||||
|
||||
- 🔄 **Мониторинг Remnawave** — регулярная проверка API, автоматическое включение/выключение техработ и сообщения в топики.
|
||||
- 🛡️ **Антифрод** — валидация обязательной подписки на канал, проверка уникальности устройств и логирование действий.
|
||||
- 🧠 **Асинхронная архитектура** — aiogram 3, PostgreSQL/SQLite, Redis и очередь задач для стабильной работы под нагрузкой.
|
||||
- 🌐 **Мультиязычность** — локализации в `locales/`, быстрый выбор языка пользователем, готовность к расширению.
|
||||
- 📦 **Интеграция с Remnawave API** — автоматическое создание пользователей, управление сквадами и синхронизация подписок.
|
||||
- 🧾 **История операций** — хранение транзакций, уведомлений, рассылок, кампаний и бекапов для аудита.
|
||||
|
||||
### 🧭 Навигация по проекту
|
||||
|
||||
- `app/bot.py` — точка входа бота и регистрация middlewares/handlers.
|
||||
- `app/handlers/` — сценарии общения (пользовательские и административные).
|
||||
- `app/services/` — бизнес-логика: подписки, платежи, мониторинг, бекапы, кампании, версия.
|
||||
- `app/database/` — модели SQLAlchemy, CRUD и миграции Alembic.
|
||||
- `app/localization/` и `locales/` — тексты интерфейса и переводов.
|
||||
- `migrations/` — текущие миграции БД для PostgreSQL.
|
||||
|
||||
## Локализации
|
||||
|
||||
- Каталог `locales/` содержит файлы локализаций в формате `.yml` или `.json` (например, `ru.yml`, `en.yml`). Эти файлы можно редактировать напрямую на рабочем сервере.
|
||||
- Структура YAML/JSON повторяет иерархию ключей, объявленных в `app/localization/texts.py`. Например, блок `MENU:` и пара `BALANCE: "Баланс"` соответствуют ключу `MENU_BALANCE`. При добавлении новых ключей сохраняйте верхний регистр и единый стиль именования.
|
||||
- Порядок внесения правок:
|
||||
1. Измените нужные значения в файле `locales/<язык>.yml` (или `.json`).
|
||||
2. Перезапустите бота/контейнер (`docker compose restart bot` в Docker) — при старте приложение перечитает локали.
|
||||
- Путь до пользовательских локалей задаётся переменной `LOCALES_PATH` (`./locales` по умолчанию; в Docker каталог монтируется в `/app/locales`, см. `docker-compose.yml`).
|
||||
- При первом запуске, если каталог пуст, бот копирует шаблоны `ru.yml` и `en.yml` — благодаря этому у владельца сразу есть заготовки для редактирования.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 💻 Локальная разработка
|
||||
|
||||
### 🚀 Быстрый запуск для разработки
|
||||
|
||||
Для локальной разработки используйте специальный Docker Compose файл:
|
||||
|
||||
```bash
|
||||
# 1. Клонируйте репозиторий
|
||||
git clone https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
|
||||
# 2. Настройте окружение
|
||||
cp env.example .env
|
||||
# Отредактируйте .env файл с вашими настройками
|
||||
|
||||
# 3. Запустите локально (Windows PowerShell)
|
||||
.\start-local.ps1
|
||||
|
||||
# Или вручную
|
||||
docker-compose -f docker-compose.local.yml up --build
|
||||
```
|
||||
|
||||
### 📁 Файлы для локальной разработки
|
||||
|
||||
- `docker-compose.local.yml` - Docker Compose для локальной разработки
|
||||
- `env.example` - Пример конфигурации
|
||||
- `start-local.ps1` - Скрипт быстрого запуска (Windows)
|
||||
- `start-local.sh` - Скрипт быстрого запуска (Linux/macOS)
|
||||
- `stop-local.ps1` - Скрипт остановки (Windows)
|
||||
- `README_LOCAL.md` - Подробная документация по локальной разработке
|
||||
|
||||
### 🔧 Особенности локальной разработки
|
||||
|
||||
- **Локальная сборка** - образ собирается из исходного кода
|
||||
- **Горячая перезагрузка** - изменения в коде автоматически применяются
|
||||
- **Доступ к БД** - PostgreSQL доступен на localhost:5432
|
||||
- **Доступ к Redis** - Redis доступен на localhost:6379
|
||||
- **Логи в реальном времени** - все логи выводятся в консоль
|
||||
- **Отладка** - полный доступ к контейнерам для отладки
|
||||
|
||||
### 📊 Мониторинг разработки
|
||||
|
||||
```bash
|
||||
# Просмотр логов
|
||||
docker-compose -f docker-compose.local.yml logs -f bot
|
||||
|
||||
# Проверка статуса
|
||||
docker-compose -f docker-compose.local.yml ps
|
||||
|
||||
# Health check
|
||||
curl http://localhost:8081/health
|
||||
|
||||
# Остановка
|
||||
docker-compose -f docker-compose.local.yml down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker развертывание
|
||||
|
||||
+24
-9
@@ -15,19 +15,29 @@ from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, common
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
)
|
||||
from app.handlers.admin import (
|
||||
main as admin_main, users as admin_users, subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes, messages as admin_messages,
|
||||
monitoring as admin_monitoring, referrals as admin_referrals,
|
||||
rules as admin_rules, remnawave as admin_remnawave,
|
||||
statistics as admin_statistics, servers as admin_servers,
|
||||
main as admin_main,
|
||||
users as admin_users,
|
||||
subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes,
|
||||
messages as admin_messages,
|
||||
monitoring as admin_monitoring,
|
||||
referrals as admin_referrals,
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates, backup as admin_backup,
|
||||
welcome_text as admin_welcome_text
|
||||
updates as admin_updates,
|
||||
backup as admin_backup,
|
||||
welcome_text as admin_welcome_text,
|
||||
tickets as admin_tickets,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -108,6 +118,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
promocode.register_handlers(dp)
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
server_status.register_handlers(dp)
|
||||
tickets.register_handlers(dp)
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
@@ -119,11 +131,14 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
admin_updates.register_handlers(dp)
|
||||
admin_backup.register_handlers(dp)
|
||||
admin_welcome_text.register_welcome_text_handlers(dp)
|
||||
admin_tickets.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
+243
-11
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
import html
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
@@ -12,10 +13,14 @@ class Settings(BaseSettings):
|
||||
BOT_TOKEN: str
|
||||
ADMIN_IDS: str = ""
|
||||
SUPPORT_USERNAME: str = "@support"
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
|
||||
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
@@ -30,6 +35,7 @@ class Settings(BaseSettings):
|
||||
POSTGRES_PASSWORD: str = "secure_password_123"
|
||||
|
||||
SQLITE_PATH: str = "./data/bot.db"
|
||||
LOCALES_PATH: str = "./locales"
|
||||
|
||||
DATABASE_MODE: str = "auto"
|
||||
|
||||
@@ -58,8 +64,17 @@ class Settings(BaseSettings):
|
||||
ENABLE_NOTIFICATIONS: bool = True
|
||||
NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
|
||||
MONITORING_LOGS_RETENTION_DAYS: int = 30
|
||||
NOTIFICATION_CACHE_HOURS: int = 24
|
||||
MONITORING_LOGS_RETENTION_DAYS: int = 30
|
||||
NOTIFICATION_CACHE_HOURS: int = 24
|
||||
|
||||
SERVER_STATUS_MODE: str = "disabled"
|
||||
SERVER_STATUS_EXTERNAL_URL: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_URL: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_USERNAME: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_PASSWORD: Optional[str] = None
|
||||
SERVER_STATUS_METRICS_VERIFY_SSL: bool = True
|
||||
SERVER_STATUS_REQUEST_TIMEOUT: int = 10
|
||||
SERVER_STATUS_ITEMS_PER_PAGE: int = 10
|
||||
|
||||
BASE_SUBSCRIPTION_PRICE: int = 50000
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
|
||||
@@ -70,7 +85,7 @@ class Settings(BaseSettings):
|
||||
PRICE_90_DAYS: int = 269000
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
|
||||
|
||||
PRICE_TRAFFIC_5GB: int = 2000
|
||||
PRICE_TRAFFIC_10GB: int = 3500
|
||||
PRICE_TRAFFIC_25GB: int = 7000
|
||||
@@ -82,9 +97,12 @@ class Settings(BaseSettings):
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 20000
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG: str = ""
|
||||
|
||||
|
||||
PRICE_PER_DEVICE: int = 5000
|
||||
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
|
||||
|
||||
TRAFFIC_SELECTION_MODE: str = "selectable"
|
||||
FIXED_TRAFFIC_LIMIT_GB: int = 100
|
||||
|
||||
@@ -187,6 +205,54 @@ class Settings(BaseSettings):
|
||||
BACKUP_SEND_ENABLED: bool = False
|
||||
BACKUP_SEND_CHAT_ID: Optional[str] = None
|
||||
BACKUP_SEND_TOPIC_ID: Optional[int] = None
|
||||
|
||||
@field_validator('SERVER_STATUS_MODE', mode='before')
|
||||
@classmethod
|
||||
def normalize_server_status_mode(cls, value: Optional[str]) -> str:
|
||||
if not value:
|
||||
return "disabled"
|
||||
|
||||
normalized = str(value).strip().lower()
|
||||
aliases = {
|
||||
"off": "disabled",
|
||||
"none": "disabled",
|
||||
"disabled": "disabled",
|
||||
"external": "external_link",
|
||||
"link": "external_link",
|
||||
"url": "external_link",
|
||||
"external_link": "external_link",
|
||||
"xray": "xray",
|
||||
"xraychecker": "xray",
|
||||
"xray_metrics": "xray",
|
||||
"metrics": "xray",
|
||||
}
|
||||
|
||||
mode = aliases.get(normalized, normalized)
|
||||
if mode not in {"disabled", "external_link", "xray"}:
|
||||
raise ValueError("SERVER_STATUS_MODE must be one of: disabled, external_link, xray")
|
||||
return mode
|
||||
|
||||
@field_validator('SERVER_STATUS_ITEMS_PER_PAGE', mode='before')
|
||||
@classmethod
|
||||
def ensure_positive_server_status_page_size(cls, value: Optional[int]) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return 10
|
||||
value_int = int(value)
|
||||
return max(1, value_int)
|
||||
except (TypeError, ValueError):
|
||||
return 10
|
||||
|
||||
@field_validator('SERVER_STATUS_REQUEST_TIMEOUT', mode='before')
|
||||
@classmethod
|
||||
def ensure_positive_server_status_timeout(cls, value: Optional[int]) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return 10
|
||||
value_int = int(value)
|
||||
return max(1, value_int)
|
||||
except (TypeError, ValueError):
|
||||
return 10
|
||||
|
||||
@field_validator('LOG_FILE', mode='before')
|
||||
@classmethod
|
||||
@@ -397,7 +463,46 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_maintenance_check_interval(self) -> int:
|
||||
return self.MAINTENANCE_CHECK_INTERVAL
|
||||
|
||||
|
||||
def is_base_promo_group_period_discount_enabled(self) -> bool:
|
||||
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
|
||||
|
||||
def get_base_promo_group_period_discounts(self) -> Dict[int, int]:
|
||||
try:
|
||||
config_str = (self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS or "").strip()
|
||||
if not config_str:
|
||||
return {}
|
||||
|
||||
discounts: Dict[int, int] = {}
|
||||
for part in config_str.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
period_and_discount = part.split(':')
|
||||
if len(period_and_discount) != 2:
|
||||
continue
|
||||
|
||||
period_str, discount_str = period_and_discount
|
||||
try:
|
||||
period_days = int(period_str.strip())
|
||||
discount_percent = int(discount_str.strip())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
discounts[period_days] = max(0, min(100, discount_percent))
|
||||
|
||||
return discounts
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_base_promo_group_period_discount(self, period_days: Optional[int]) -> int:
|
||||
if not period_days or not self.is_base_promo_group_period_discount_enabled():
|
||||
return 0
|
||||
|
||||
discounts = self.get_base_promo_group_period_discounts()
|
||||
return discounts.get(period_days, 0)
|
||||
|
||||
def is_maintenance_auto_enable(self) -> bool:
|
||||
return self.MAINTENANCE_AUTO_ENABLE
|
||||
|
||||
@@ -602,13 +707,140 @@ class Settings(BaseSettings):
|
||||
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
|
||||
]
|
||||
|
||||
def get_traffic_price(self, gb: int) -> int:
|
||||
def get_traffic_price(self, gb: Optional[int]) -> int:
|
||||
packages = self.get_traffic_packages()
|
||||
|
||||
for package in packages:
|
||||
if package["gb"] == gb and package["enabled"]:
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
if gb is None:
|
||||
gb = 0
|
||||
|
||||
for package in enabled_packages:
|
||||
if package["gb"] == gb:
|
||||
return package["price"]
|
||||
|
||||
|
||||
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
|
||||
|
||||
if gb <= 0:
|
||||
return unlimited_package["price"] if unlimited_package else 0
|
||||
|
||||
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
|
||||
|
||||
if not finite_packages:
|
||||
return unlimited_package["price"] if unlimited_package else 0
|
||||
|
||||
max_package = max(finite_packages, key=lambda x: x["gb"])
|
||||
|
||||
if gb >= max_package["gb"]:
|
||||
return unlimited_package["price"] if unlimited_package else max_package["price"]
|
||||
|
||||
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
|
||||
|
||||
if suitable_packages:
|
||||
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
|
||||
return nearest_package["price"]
|
||||
|
||||
return unlimited_package["price"] if unlimited_package else 0
|
||||
|
||||
def _clean_support_contact(self) -> str:
|
||||
return (self.SUPPORT_USERNAME or "").strip()
|
||||
|
||||
def get_support_contact_url(self) -> Optional[str]:
|
||||
contact = self._clean_support_contact()
|
||||
|
||||
if not contact:
|
||||
return None
|
||||
|
||||
if contact.startswith(("http://", "https://", "tg://")):
|
||||
return contact
|
||||
|
||||
contact_without_prefix = contact.lstrip("@")
|
||||
|
||||
if contact_without_prefix.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
|
||||
return f"https://{contact_without_prefix}"
|
||||
|
||||
if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
|
||||
return f"https://{contact}"
|
||||
|
||||
if "." in contact_without_prefix:
|
||||
return f"https://{contact_without_prefix}"
|
||||
|
||||
if contact_without_prefix:
|
||||
return f"https://t.me/{contact_without_prefix}"
|
||||
|
||||
return None
|
||||
|
||||
def get_support_contact_display(self) -> str:
|
||||
contact = self._clean_support_contact()
|
||||
|
||||
if not contact:
|
||||
return ""
|
||||
|
||||
if contact.startswith("@"):
|
||||
return contact
|
||||
|
||||
if contact.startswith(("http://", "https://", "tg://")):
|
||||
return contact
|
||||
|
||||
if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
|
||||
url = self.get_support_contact_url()
|
||||
return url if url else contact
|
||||
|
||||
contact_without_prefix = contact.lstrip("@")
|
||||
|
||||
if "." in contact_without_prefix:
|
||||
url = self.get_support_contact_url()
|
||||
return url if url else contact
|
||||
|
||||
if re.fullmatch(r"[A-Za-z0-9_]{3,}", contact_without_prefix):
|
||||
return f"@{contact_without_prefix}"
|
||||
|
||||
return contact
|
||||
|
||||
def get_support_contact_display_html(self) -> str:
|
||||
return html.escape(self.get_support_contact_display())
|
||||
|
||||
def get_server_status_mode(self) -> str:
|
||||
return self.SERVER_STATUS_MODE
|
||||
|
||||
def is_server_status_enabled(self) -> bool:
|
||||
return self.get_server_status_mode() != "disabled"
|
||||
|
||||
def get_server_status_external_url(self) -> Optional[str]:
|
||||
url = (self.SERVER_STATUS_EXTERNAL_URL or "").strip()
|
||||
return url or None
|
||||
|
||||
def get_server_status_metrics_url(self) -> Optional[str]:
|
||||
url = (self.SERVER_STATUS_METRICS_URL or "").strip()
|
||||
return url or None
|
||||
|
||||
def get_server_status_metrics_auth(self) -> Optional[tuple[str, str]]:
|
||||
username = (self.SERVER_STATUS_METRICS_USERNAME or "").strip()
|
||||
password_raw = self.SERVER_STATUS_METRICS_PASSWORD
|
||||
|
||||
if not username:
|
||||
return None
|
||||
|
||||
password = "" if password_raw is None else str(password_raw)
|
||||
return username, password
|
||||
|
||||
def get_server_status_items_per_page(self) -> int:
|
||||
return max(1, self.SERVER_STATUS_ITEMS_PER_PAGE)
|
||||
|
||||
def get_server_status_request_timeout(self) -> int:
|
||||
return max(1, self.SERVER_STATUS_REQUEST_TIMEOUT)
|
||||
|
||||
def get_support_system_mode(self) -> str:
|
||||
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
def is_support_tickets_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"tickets", "both"}
|
||||
|
||||
def is_support_contact_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"contact", "both"}
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
if not enabled_packages:
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, func, select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_campaign(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
start_parameter: str,
|
||||
bonus_type: str,
|
||||
created_by: Optional[int] = None,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_duration_days: Optional[int] = None,
|
||||
subscription_traffic_gb: Optional[int] = None,
|
||||
subscription_device_limit: Optional[int] = None,
|
||||
subscription_squads: Optional[List[str]] = None,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
start_parameter=start_parameter,
|
||||
bonus_type=bonus_type,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=subscription_duration_days,
|
||||
subscription_traffic_gb=subscription_traffic_gb,
|
||||
subscription_device_limit=subscription_device_limit,
|
||||
subscription_squads=subscription_squads or [],
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info(
|
||||
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
|
||||
campaign.name,
|
||||
campaign.start_parameter,
|
||||
campaign.bonus_type,
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
async def get_campaign_by_id(
|
||||
db: AsyncSession, campaign_id: int
|
||||
) -> Optional[AdvertisingCampaign]:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaign)
|
||||
.options(selectinload(AdvertisingCampaign.registrations))
|
||||
.where(AdvertisingCampaign.id == campaign_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaign_by_start_parameter(
|
||||
db: AsyncSession,
|
||||
start_parameter: str,
|
||||
*,
|
||||
only_active: bool = False,
|
||||
) -> Optional[AdvertisingCampaign]:
|
||||
stmt = select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.start_parameter == start_parameter
|
||||
)
|
||||
if only_active:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaigns_list(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
include_inactive: bool = True,
|
||||
) -> List[AdvertisingCampaign]:
|
||||
stmt = (
|
||||
select(AdvertisingCampaign)
|
||||
.options(selectinload(AdvertisingCampaign.registrations))
|
||||
.order_by(AdvertisingCampaign.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_campaigns_count(
|
||||
db: AsyncSession, *, is_active: Optional[bool] = None
|
||||
) -> int:
|
||||
stmt = select(func.count(AdvertisingCampaign.id))
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one() or 0
|
||||
|
||||
|
||||
async def update_campaign(
|
||||
db: AsyncSession,
|
||||
campaign: AdvertisingCampaign,
|
||||
**kwargs,
|
||||
) -> AdvertisingCampaign:
|
||||
allowed_fields = {
|
||||
"name",
|
||||
"start_parameter",
|
||||
"bonus_type",
|
||||
"balance_bonus_kopeks",
|
||||
"subscription_duration_days",
|
||||
"subscription_traffic_gb",
|
||||
"subscription_device_limit",
|
||||
"subscription_squads",
|
||||
"is_active",
|
||||
}
|
||||
|
||||
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
|
||||
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
|
||||
await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(AdvertisingCampaign.id == campaign.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data)
|
||||
return campaign
|
||||
|
||||
|
||||
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
|
||||
await db.execute(
|
||||
delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
|
||||
return True
|
||||
|
||||
|
||||
async def get_campaign_registration_by_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[AdvertisingCampaignRegistration]:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaignRegistration)
|
||||
.options(selectinload(AdvertisingCampaignRegistration.campaign))
|
||||
.where(AdvertisingCampaignRegistration.user_id == user_id)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_campaign_registration(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
campaign_id: int,
|
||||
user_id: int,
|
||||
bonus_type: str,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_duration_days: Optional[int] = None,
|
||||
) -> AdvertisingCampaignRegistration:
|
||||
existing = await db.execute(
|
||||
select(AdvertisingCampaignRegistration).where(
|
||||
and_(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
AdvertisingCampaignRegistration.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
registration = existing.scalar_one_or_none()
|
||||
if registration:
|
||||
return registration
|
||||
|
||||
registration = AdvertisingCampaignRegistration(
|
||||
campaign_id=campaign_id,
|
||||
user_id=user_id,
|
||||
bonus_type=bonus_type,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=subscription_duration_days,
|
||||
)
|
||||
db.add(registration)
|
||||
await db.commit()
|
||||
await db.refresh(registration)
|
||||
|
||||
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
|
||||
return registration
|
||||
|
||||
|
||||
async def get_campaign_statistics(
|
||||
db: AsyncSession,
|
||||
campaign_id: int,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
registrations_query = select(AdvertisingCampaignRegistration.user_id).where(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id
|
||||
)
|
||||
registrations_subquery = registrations_query.subquery()
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(AdvertisingCampaignRegistration.id),
|
||||
func.coalesce(
|
||||
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
|
||||
),
|
||||
func.max(AdvertisingCampaignRegistration.created_at),
|
||||
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
)
|
||||
count, total_balance, last_registration = result.one()
|
||||
count = count or 0
|
||||
total_balance = total_balance or 0
|
||||
|
||||
subscription_count_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
and_(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
AdvertisingCampaignRegistration.bonus_type == "subscription",
|
||||
)
|
||||
)
|
||||
)
|
||||
subscription_bonuses_issued = subscription_count_result.scalar() or 0
|
||||
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
)
|
||||
deposits_total = deposits_result.scalar() or 0
|
||||
|
||||
trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
)
|
||||
)
|
||||
trial_users_count = trials_result.scalar() or 0
|
||||
|
||||
active_trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
active_trials_count = active_trials_result.scalar() or 0
|
||||
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
conversion_count = conversions_result.scalar() or 0
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.id.in_(select(registrations_subquery.c.user_id)),
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
paid_users_from_flag = paid_users_result.scalar() or 0
|
||||
|
||||
conversions_rows = await db.execute(
|
||||
select(
|
||||
SubscriptionConversion.user_id,
|
||||
SubscriptionConversion.first_payment_amount_kopeks,
|
||||
SubscriptionConversion.converted_at,
|
||||
)
|
||||
.where(
|
||||
SubscriptionConversion.user_id.in_(
|
||||
select(registrations_subquery.c.user_id)
|
||||
)
|
||||
)
|
||||
.order_by(SubscriptionConversion.converted_at)
|
||||
)
|
||||
conversion_entries = conversions_rows.all()
|
||||
|
||||
subscription_payments_rows = await db.execute(
|
||||
select(
|
||||
Transaction.user_id,
|
||||
Transaction.amount_kopeks,
|
||||
Transaction.created_at,
|
||||
)
|
||||
.where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
.order_by(Transaction.user_id, Transaction.created_at)
|
||||
)
|
||||
subscription_payments = subscription_payments_rows.all()
|
||||
|
||||
subscription_payments_total = 0
|
||||
paid_users_from_transactions = set()
|
||||
conversion_user_ids = set()
|
||||
first_payment_amount_by_user: Dict[int, int] = {}
|
||||
first_payment_time_by_user: Dict[int, Optional[datetime]] = {}
|
||||
|
||||
for user_id, amount_kopeks, converted_at in conversion_entries:
|
||||
conversion_user_ids.add(user_id)
|
||||
amount_value = int(amount_kopeks or 0)
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = converted_at
|
||||
|
||||
for user_id, amount_kopeks, created_at in subscription_payments:
|
||||
amount_value = int(amount_kopeks or 0)
|
||||
subscription_payments_total += amount_value
|
||||
paid_users_from_transactions.add(user_id)
|
||||
|
||||
if user_id not in first_payment_amount_by_user:
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
else:
|
||||
existing_time = first_payment_time_by_user.get(user_id)
|
||||
if existing_time is None and created_at is not None:
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
elif (
|
||||
existing_time is not None
|
||||
and created_at is not None
|
||||
and created_at < existing_time
|
||||
):
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
|
||||
total_revenue = deposits_total + subscription_payments_total
|
||||
|
||||
paid_user_ids = set(paid_users_from_transactions)
|
||||
paid_user_ids.update(conversion_user_ids)
|
||||
paid_users_count = max(len(paid_user_ids), paid_users_from_flag)
|
||||
|
||||
conversion_count = conversion_count or len(paid_user_ids)
|
||||
if conversion_count < len(paid_user_ids):
|
||||
conversion_count = len(paid_user_ids)
|
||||
|
||||
avg_first_payment = 0
|
||||
if first_payment_amount_by_user:
|
||||
avg_first_payment = int(
|
||||
sum(first_payment_amount_by_user.values())
|
||||
/ len(first_payment_amount_by_user)
|
||||
)
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
trial_conversion_rate = 0.0
|
||||
if trial_users_count:
|
||||
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
|
||||
|
||||
avg_revenue_per_user = 0
|
||||
if count:
|
||||
avg_revenue_per_user = int(total_revenue / count)
|
||||
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
)
|
||||
total_revenue = deposits_result.scalar() or 0
|
||||
|
||||
trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
)
|
||||
)
|
||||
trial_users_count = trials_result.scalar() or 0
|
||||
|
||||
active_trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
active_trials_count = active_trials_result.scalar() or 0
|
||||
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
conversion_count = conversions_result.scalar() or 0
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.id.in_(select(registrations_subquery.c.user_id)),
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
paid_users_count = paid_users_result.scalar() or 0
|
||||
|
||||
avg_first_payment_result = await db.execute(
|
||||
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
trial_conversion_rate = 0.0
|
||||
if trial_users_count:
|
||||
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
|
||||
|
||||
avg_revenue_per_user = 0
|
||||
if count:
|
||||
avg_revenue_per_user = int(total_revenue / count)
|
||||
|
||||
return {
|
||||
"registrations": count,
|
||||
"balance_issued": total_balance,
|
||||
"subscription_issued": subscription_bonuses_issued,
|
||||
"last_registration": last_registration,
|
||||
"total_revenue_kopeks": total_revenue,
|
||||
"trial_users_count": trial_users_count,
|
||||
"active_trials_count": active_trials_count,
|
||||
"conversion_count": conversion_count,
|
||||
"paid_users_count": paid_users_count,
|
||||
"conversion_rate": conversion_rate,
|
||||
"trial_conversion_rate": trial_conversion_rate,
|
||||
"avg_revenue_per_user_kopeks": avg_revenue_per_user,
|
||||
"avg_first_payment_kopeks": avg_first_payment,
|
||||
}
|
||||
|
||||
|
||||
async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
|
||||
total = await get_campaigns_count(db)
|
||||
active = await get_campaigns_count(db, is_active=True)
|
||||
inactive = await get_campaigns_count(db, is_active=False)
|
||||
|
||||
registrations_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id))
|
||||
)
|
||||
|
||||
balance_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(
|
||||
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
subscription_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.bonus_type == "subscription"
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"active": active,
|
||||
"inactive": inactive,
|
||||
"registrations": registrations_result.scalar() or 0,
|
||||
"balance_total": balance_result.scalar() or 0,
|
||||
"subscription_total": subscription_result.scalar() or 0,
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
db: AsyncSession,
|
||||
) -> List[Tuple[PromoGroup, int]]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
|
||||
)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
|
||||
return await db.get(PromoGroup, group_id)
|
||||
|
||||
|
||||
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.is_default.is_(True))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def create_promo_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
) -> PromoGroup:
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
db.add(promo_group)
|
||||
await db.commit()
|
||||
await db.refresh(promo_group)
|
||||
|
||||
logger.info(
|
||||
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)",
|
||||
promo_group.name,
|
||||
promo_group.server_discount_percent,
|
||||
promo_group.traffic_discount_percent,
|
||||
promo_group.device_discount_percent,
|
||||
)
|
||||
|
||||
return promo_group
|
||||
|
||||
|
||||
async def update_promo_group(
|
||||
db: AsyncSession,
|
||||
group: PromoGroup,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
server_discount_percent: Optional[int] = None,
|
||||
traffic_discount_percent: Optional[int] = None,
|
||||
device_discount_percent: Optional[int] = None,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
if server_discount_percent is not None:
|
||||
group.server_discount_percent = max(0, min(100, server_discount_percent))
|
||||
if traffic_discount_percent is not None:
|
||||
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
|
||||
if device_discount_percent is not None:
|
||||
group.device_discount_percent = max(0, min(100, device_discount_percent))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
logger.info(
|
||||
"Обновлена промогруппа '%s' (id=%s)",
|
||||
group.name,
|
||||
group.id,
|
||||
)
|
||||
return group
|
||||
|
||||
|
||||
async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
if group.is_default:
|
||||
logger.warning("Попытка удалить базовую промогруппу запрещена")
|
||||
return False
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
logger.error("Не найдена базовая промогруппа для reassignment")
|
||||
return False
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
.values(promo_group_id=default_group.id)
|
||||
)
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
|
||||
group.name,
|
||||
group.id,
|
||||
default_group.name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def get_promo_group_members(
|
||||
db: AsyncSession,
|
||||
group_id: int,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.promo_group_id == group_id)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_promo_group_members(db: AsyncSession, group_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count(User.id)).where(User.promo_group_id == group_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
@@ -6,8 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
SubscriptionServer,
|
||||
PromoGroup,
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
@@ -495,12 +498,34 @@ async def get_servers_monthly_prices(
|
||||
prices.append(price)
|
||||
return prices
|
||||
|
||||
def _get_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int
|
||||
devices: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, dict]:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
@@ -508,39 +533,98 @@ async def calculate_subscription_total_cost(
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'traffic_discount_percent': traffic_discount_percent,
|
||||
'traffic_discount_total': total_traffic_discount,
|
||||
'total_traffic_price': total_traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'servers_discount_percent': servers_discount_percent,
|
||||
'servers_discount_total': total_servers_discount,
|
||||
'total_servers_price': total_servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'devices_discount_percent': devices_discount_percent,
|
||||
'devices_discount_total': total_devices_discount,
|
||||
'total_devices_price': total_devices_price,
|
||||
'months_in_period': months_in_period,
|
||||
'servers_individual_prices': [price * months_in_period for price in servers_prices]
|
||||
'servers_individual_prices': [
|
||||
(price - (price * servers_discount_percent // 100)) * months_in_period
|
||||
for price in servers_prices
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
message = (
|
||||
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
message = (
|
||||
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
message = (
|
||||
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
@@ -614,19 +698,33 @@ async def remove_subscription_servers(
|
||||
async def get_subscription_renewal_cost(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
period_days: int
|
||||
period_days: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
subscription = await db.get(Subscription, subscription_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
|
||||
if user is None:
|
||||
user = subscription.user
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
@@ -637,26 +735,74 @@ async def get_subscription_renewal_cost(
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
total_servers_cost = servers_price_per_month * months_in_period
|
||||
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_cost = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_cost = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_cost = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_cost = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_cost = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период: {base_price/100}₽")
|
||||
if total_servers_cost > 0:
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽")
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_traffic_cost > 0:
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽")
|
||||
message = (
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽"
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_cost > 0:
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽")
|
||||
message = (
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽"
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost
|
||||
@@ -671,27 +817,65 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None
|
||||
additional_server_ids: List[int] = None,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
|
||||
total_cost = 0
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_total_cost = traffic_price_per_month * months_to_pay
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_cost = discounted_traffic_per_month * months_to_pay
|
||||
total_cost += traffic_total_cost
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽")
|
||||
|
||||
message = (
|
||||
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_cost = devices_price_per_month * months_to_pay
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_cost = discounted_devices_per_month * months_to_pay
|
||||
total_cost += devices_total_cost
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽")
|
||||
|
||||
message = (
|
||||
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in additional_server_ids:
|
||||
@@ -702,9 +886,24 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
server_total_cost = server_price_per_month * months_to_pay
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_cost = discounted_server_per_month * months_to_pay
|
||||
total_cost += server_total_cost
|
||||
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽")
|
||||
message = (
|
||||
f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽")
|
||||
return total_cost
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_, or_, update, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus, User
|
||||
|
||||
|
||||
class TicketCRUD:
|
||||
"""CRUD операции для работы с тикетами"""
|
||||
|
||||
@staticmethod
|
||||
async def create_ticket(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message_text: str,
|
||||
priority: str = "normal",
|
||||
*,
|
||||
media_type: Optional[str] = None,
|
||||
media_file_id: Optional[str] = None,
|
||||
media_caption: Optional[str] = None,
|
||||
) -> Ticket:
|
||||
"""Создать новый тикет с первым сообщением"""
|
||||
ticket = Ticket(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
status=TicketStatus.OPEN.value,
|
||||
priority=priority
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush() # Получаем ID тикета
|
||||
|
||||
# Создаем первое сообщение
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=False,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_by_id(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
load_messages: bool = True,
|
||||
load_user: bool = False
|
||||
) -> Optional[Ticket]:
|
||||
"""Получить тикет по ID"""
|
||||
query = select(Ticket).where(Ticket.id == ticket_id)
|
||||
|
||||
if load_user:
|
||||
query = query.options(selectinload(Ticket.user))
|
||||
|
||||
if load_messages:
|
||||
query = query.options(selectinload(Ticket.messages))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tickets(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить тикеты пользователя"""
|
||||
query = select(Ticket).where(Ticket.user_id == user_id)
|
||||
|
||||
if status:
|
||||
query = query.where(Ticket.status == status)
|
||||
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def user_has_active_ticket(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
"""Проверить, есть ли у пользователя активный (не закрытый) тикет"""
|
||||
query = (
|
||||
select(Ticket.id)
|
||||
.where(
|
||||
Ticket.user_id == user_id,
|
||||
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@staticmethod
|
||||
async def is_user_globally_blocked(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[datetime]:
|
||||
"""Проверить, заблокирован ли пользователь для создания/ответов по любому тикету.
|
||||
Возвращает дату окончания блокировки, если активна, или None.
|
||||
"""
|
||||
query = select(Ticket).where(
|
||||
Ticket.user_id == user_id,
|
||||
or_(Ticket.user_reply_block_permanent == True, Ticket.user_reply_block_until.isnot(None))
|
||||
).order_by(desc(Ticket.updated_at)).limit(10)
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
if not tickets:
|
||||
return None
|
||||
from datetime import datetime
|
||||
# Если есть вечная блокировка в любом тикете — блок активен без срока
|
||||
for t in tickets:
|
||||
if t.user_reply_block_permanent:
|
||||
return datetime.max
|
||||
# Иначе ищем максимальный срок блокировки, если он в будущем
|
||||
future_until = [t.user_reply_block_until for t in tickets if t.user_reply_block_until]
|
||||
if not future_until:
|
||||
return None
|
||||
max_until = max(future_until)
|
||||
return max_until if max_until > datetime.utcnow() else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_tickets(
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить все тикеты (для админов)"""
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
|
||||
conditions = []
|
||||
if status:
|
||||
conditions.append(Ticket.status == status)
|
||||
if priority:
|
||||
conditions.append(Ticket.priority == priority)
|
||||
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
statuses: List[str],
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_tickets(
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None
|
||||
) -> int:
|
||||
query = select(func.count()).select_from(Ticket)
|
||||
if status:
|
||||
query = query.where(Ticket.status == status)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def count_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
statuses: List[str]
|
||||
) -> int:
|
||||
query = select(func.count()).select_from(Ticket)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def update_ticket_status(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
status: str,
|
||||
closed_at: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""Обновить статус тикета"""
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
return False
|
||||
|
||||
ticket.status = status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
if status == TicketStatus.CLOSED.value and closed_at:
|
||||
ticket.closed_at = closed_at
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def set_user_reply_block(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
permanent: bool,
|
||||
until: Optional[datetime]
|
||||
) -> bool:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
return False
|
||||
ticket.user_reply_block_permanent = bool(permanent)
|
||||
ticket.user_reply_block_until = until
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def close_ticket(
|
||||
db: AsyncSession,
|
||||
ticket_id: int
|
||||
) -> bool:
|
||||
"""Закрыть тикет"""
|
||||
return await TicketCRUD.update_ticket_status(
|
||||
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_open_tickets_count(db: AsyncSession) -> int:
|
||||
"""Получить количество открытых тикетов"""
|
||||
query = select(Ticket).where(Ticket.status.in_([
|
||||
TicketStatus.OPEN.value,
|
||||
TicketStatus.ANSWERED.value
|
||||
]))
|
||||
result = await db.execute(query)
|
||||
return len(result.scalars().all())
|
||||
|
||||
|
||||
class TicketMessageCRUD:
|
||||
"""CRUD операции для работы с сообщениями тикетов"""
|
||||
|
||||
@staticmethod
|
||||
async def add_message(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
user_id: int,
|
||||
message_text: str,
|
||||
is_from_admin: bool = False,
|
||||
media_type: Optional[str] = None,
|
||||
media_file_id: Optional[str] = None,
|
||||
media_caption: Optional[str] = None
|
||||
) -> TicketMessage:
|
||||
"""Добавить сообщение в тикет"""
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket_id,
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=is_from_admin,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
|
||||
# Обновляем статус тикета
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if ticket:
|
||||
# Если тикет закрыт, запрещаем изменение статуса при сообщении пользователя
|
||||
if not is_from_admin and ticket.status == TicketStatus.CLOSED.value:
|
||||
return message
|
||||
if is_from_admin:
|
||||
# Админ ответил - тикет отвечен
|
||||
ticket.status = TicketStatus.ANSWERED.value
|
||||
else:
|
||||
# Пользователь ответил - тикет открыт
|
||||
ticket.status = TicketStatus.OPEN.value
|
||||
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_messages(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[TicketMessage]:
|
||||
"""Получить сообщения тикета"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(TicketMessage.created_at)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_last_message(
|
||||
db: AsyncSession,
|
||||
ticket_id: int
|
||||
) -> Optional[TicketMessage]:
|
||||
"""Получить последнее сообщение в тикете"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(desc(TicketMessage.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
+50
-10
@@ -7,8 +7,9 @@ from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +23,10 @@ def generate_referral_code() -> str:
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -36,7 +40,10 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -49,7 +56,9 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User).where(User.referral_code == referral_code)
|
||||
select(User)
|
||||
.options(selectinload(User.promo_group))
|
||||
.where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -82,6 +91,20 @@ async def create_user(
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
|
||||
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,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
|
||||
promo_group_id = default_group.id
|
||||
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
@@ -92,15 +115,19 @@ async def create_user(
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=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
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -216,7 +243,8 @@ async def get_users_list(
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[UserStatus] = None
|
||||
status: Optional[UserStatus] = None,
|
||||
order_by_balance: bool = False
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
@@ -237,7 +265,13 @@ async def get_users_list(
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
# Сортировка по балансу в порядке убывания, если order_by_balance=True
|
||||
if order_by_balance:
|
||||
query = query.order_by(User.balance_kopeks.desc())
|
||||
else:
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
@@ -274,7 +308,10 @@ async def get_users_count(
|
||||
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
@@ -286,7 +323,10 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity < threshold_date,
|
||||
|
||||
+227
-4
@@ -3,8 +3,17 @@ from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, DateTime, Boolean, Text,
|
||||
ForeignKey, Float, JSON, BigInteger
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
Boolean,
|
||||
Text,
|
||||
ForeignKey,
|
||||
Float,
|
||||
JSON,
|
||||
BigInteger,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
@@ -146,6 +155,43 @@ class CryptoBotPayment(Base):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), unique=True, nullable=False)
|
||||
server_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
is_default = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
users = relationship("User", back_populates="promo_group")
|
||||
|
||||
def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
mapping = {
|
||||
"servers": self.server_discount_percent,
|
||||
"traffic": self.traffic_discount_percent,
|
||||
"devices": self.device_discount_percent,
|
||||
}
|
||||
percent = mapping.get(category, 0)
|
||||
|
||||
if self.is_default and period_days is not None:
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_base_promo_group_period_discount_enabled():
|
||||
discounts = settings.get_base_promo_group_period_discounts()
|
||||
if period_days in discounts:
|
||||
period_discount = discounts[period_days]
|
||||
percent = period_discount
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -176,6 +222,8 @@ class User(Base):
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
ss_password = Column(String(255), nullable=True)
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -185,6 +233,11 @@ class User(Base):
|
||||
def full_name(self) -> str:
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
if not self.promo_group:
|
||||
return 0
|
||||
return self.promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
@@ -666,7 +719,7 @@ class UserMessage(Base):
|
||||
|
||||
class WelcomeText(Base):
|
||||
__tablename__ = "welcome_texts"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
text_content = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
@@ -674,5 +727,175 @@ class WelcomeText(Base):
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
creator = relationship("User", backref="created_welcome_texts")
|
||||
|
||||
|
||||
class AdvertisingCampaign(Base):
|
||||
__tablename__ = "advertising_campaigns"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
start_parameter = Column(String(64), nullable=False, unique=True, index=True)
|
||||
bonus_type = Column(String(20), nullable=False)
|
||||
|
||||
balance_bonus_kopeks = Column(Integer, default=0)
|
||||
|
||||
subscription_duration_days = Column(Integer, nullable=True)
|
||||
subscription_traffic_gb = Column(Integer, nullable=True)
|
||||
subscription_device_limit = Column(Integer, nullable=True)
|
||||
subscription_squads = Column(JSON, default=list)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign")
|
||||
|
||||
@property
|
||||
def is_balance_bonus(self) -> bool:
|
||||
return self.bonus_type == "balance"
|
||||
|
||||
@property
|
||||
def is_subscription_bonus(self) -> bool:
|
||||
return self.bonus_type == "subscription"
|
||||
|
||||
|
||||
class AdvertisingCampaignRegistration(Base):
|
||||
__tablename__ = "advertising_campaign_registrations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
bonus_type = Column(String(20), nullable=False)
|
||||
balance_bonus_kopeks = Column(Integer, default=0)
|
||||
subscription_duration_days = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
campaign = relationship("AdvertisingCampaign", back_populates="registrations")
|
||||
user = relationship("User")
|
||||
|
||||
@property
|
||||
def balance_bonus_rubles(self) -> float:
|
||||
return (self.balance_bonus_kopeks or 0) / 100
|
||||
|
||||
|
||||
class TicketStatus(Enum):
|
||||
OPEN = "open"
|
||||
ANSWERED = "answered"
|
||||
CLOSED = "closed"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
class Ticket(Base):
|
||||
__tablename__ = "tickets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
title = Column(String(255), nullable=False)
|
||||
status = Column(String(20), default=TicketStatus.OPEN.value, nullable=False)
|
||||
priority = Column(String(20), default="normal", nullable=False) # low, normal, high, urgent
|
||||
# Блокировка ответов пользователя в этом тикете
|
||||
user_reply_block_permanent = Column(Boolean, default=False, nullable=False)
|
||||
user_reply_block_until = Column(DateTime, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Связи
|
||||
user = relationship("User", backref="tickets")
|
||||
messages = relationship("TicketMessage", back_populates="ticket", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.status == TicketStatus.OPEN.value
|
||||
|
||||
@property
|
||||
def is_answered(self) -> bool:
|
||||
return self.status == TicketStatus.ANSWERED.value
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self.status == TicketStatus.CLOSED.value
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == TicketStatus.PENDING.value
|
||||
|
||||
@property
|
||||
def is_user_reply_blocked(self) -> bool:
|
||||
if self.user_reply_block_permanent:
|
||||
return True
|
||||
if self.user_reply_block_until:
|
||||
try:
|
||||
from datetime import datetime
|
||||
return self.user_reply_block_until > datetime.utcnow()
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
status_emojis = {
|
||||
TicketStatus.OPEN.value: "🔴",
|
||||
TicketStatus.ANSWERED.value: "🟡",
|
||||
TicketStatus.CLOSED.value: "🟢",
|
||||
TicketStatus.PENDING.value: "⏳"
|
||||
}
|
||||
return status_emojis.get(self.status, "❓")
|
||||
|
||||
@property
|
||||
def priority_emoji(self) -> str:
|
||||
priority_emojis = {
|
||||
"low": "🟢",
|
||||
"normal": "🟡",
|
||||
"high": "🟠",
|
||||
"urgent": "🔴"
|
||||
}
|
||||
return priority_emojis.get(self.priority, "🟡")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Ticket(id={self.id}, user_id={self.user_id}, status={self.status}, title='{self.title[:30]}...')>"
|
||||
|
||||
|
||||
class TicketMessage(Base):
|
||||
__tablename__ = "ticket_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
message_text = Column(Text, nullable=False)
|
||||
is_from_admin = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Для медиа файлов
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc.
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
media_caption = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# Связи
|
||||
ticket = relationship("Ticket", back_populates="messages")
|
||||
user = relationship("User")
|
||||
|
||||
@property
|
||||
def is_user_message(self) -> bool:
|
||||
return not self.is_from_admin
|
||||
|
||||
@property
|
||||
def is_admin_message(self) -> bool:
|
||||
return self.is_from_admin
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
||||
@@ -74,6 +74,104 @@ async def check_column_exists(table_name: str, column_name: str) -> bool:
|
||||
logger.error(f"Ошибка проверки существования колонки {column_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def check_constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "postgresql":
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = :table_name
|
||||
AND constraint_name = :constraint_name
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "constraint_name": constraint_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
if db_type == "mysql":
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = :table_name
|
||||
AND constraint_name = :constraint_name
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "constraint_name": constraint_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
if db_type == "sqlite":
|
||||
result = await conn.execute(text(f"PRAGMA foreign_key_list({table_name})"))
|
||||
rows = result.fetchall()
|
||||
return any(row[5] == constraint_name for row in rows)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка проверки существования ограничения {constraint_name} для {table_name}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def check_index_exists(table_name: str, index_name: str) -> bool:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "postgresql":
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = :table_name
|
||||
AND indexname = :index_name
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
if db_type == "mysql":
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = :table_name
|
||||
AND index_name = :index_name
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
if db_type == "sqlite":
|
||||
result = await conn.execute(text(f"PRAGMA index_list({table_name})"))
|
||||
rows = result.fetchall()
|
||||
return any(row[1] == index_name for row in rows)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка проверки существования индекса {index_name} для {table_name}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def create_cryptobot_payments_table():
|
||||
table_exists = await check_table_exists('cryptobot_payments')
|
||||
if table_exists:
|
||||
@@ -248,6 +346,276 @@ async def create_user_messages_table():
|
||||
logger.error(f"Ошибка создания таблицы user_messages: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_promo_groups_setup():
|
||||
logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===")
|
||||
|
||||
try:
|
||||
promo_table_exists = await check_table_exists("promo_groups")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not promo_table_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS promo_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
server_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
traffic_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
device_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
is_default BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_promo_groups_name ON promo_groups(name)"
|
||||
)
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS promo_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
server_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
traffic_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
device_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_promo_groups_name UNIQUE (name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS promo_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
server_discount_percent INT NOT NULL DEFAULT 0,
|
||||
traffic_discount_percent INT NOT NULL DEFAULT 0,
|
||||
device_discount_percent INT NOT NULL DEFAULT 0,
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_promo_groups_name (name)
|
||||
) ENGINE=InnoDB
|
||||
"""
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для promo_groups: {db_type}")
|
||||
return False
|
||||
|
||||
logger.info("Создана таблица promo_groups")
|
||||
|
||||
if db_type == "postgresql" and not await check_constraint_exists(
|
||||
"promo_groups", "uq_promo_groups_name"
|
||||
):
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_groups ADD CONSTRAINT uq_promo_groups_name UNIQUE (name)"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}"
|
||||
)
|
||||
|
||||
column_exists = await check_column_exists("users", "promo_group_id")
|
||||
|
||||
if not column_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INTEGER"))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INTEGER"))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INT"))
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для promo_group_id: {db_type}")
|
||||
return False
|
||||
|
||||
logger.info("Добавлена колонка users.promo_group_id")
|
||||
|
||||
index_exists = await check_index_exists("users", "ix_users_promo_group_id")
|
||||
|
||||
if not index_exists:
|
||||
try:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)")
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)")
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text("CREATE INDEX ix_users_promo_group_id ON users(promo_group_id)")
|
||||
)
|
||||
logger.info("Создан индекс ix_users_promo_group_id")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось создать индекс ix_users_promo_group_id: {e}")
|
||||
|
||||
default_group_name = "Базовый юзер"
|
||||
default_group_id = None
|
||||
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT id, is_default FROM promo_groups WHERE name = :name LIMIT 1"
|
||||
),
|
||||
{"name": default_group_name},
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if row:
|
||||
default_group_id = row[0]
|
||||
if not row[1]:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE promo_groups SET is_default = :is_default WHERE id = :group_id"
|
||||
),
|
||||
{"is_default": True, "group_id": default_group_id},
|
||||
)
|
||||
else:
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT id FROM promo_groups WHERE is_default = :is_default LIMIT 1"
|
||||
),
|
||||
{"is_default": True},
|
||||
)
|
||||
existing_default = result.fetchone()
|
||||
|
||||
if existing_default:
|
||||
default_group_id = existing_default[0]
|
||||
else:
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
is_default
|
||||
) VALUES (:name, 0, 0, 0, :is_default)
|
||||
"""
|
||||
),
|
||||
{"name": default_group_name, "is_default": True},
|
||||
)
|
||||
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT id FROM promo_groups WHERE name = :name LIMIT 1"
|
||||
),
|
||||
{"name": default_group_name},
|
||||
)
|
||||
row = result.fetchone()
|
||||
default_group_id = row[0] if row else None
|
||||
|
||||
if default_group_id is None:
|
||||
logger.error("Не удалось определить идентификатор базовой промо-группы")
|
||||
return False
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET promo_group_id = :group_id
|
||||
WHERE promo_group_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"group_id": default_group_id},
|
||||
)
|
||||
|
||||
if db_type == "postgresql":
|
||||
constraint_exists = await check_constraint_exists(
|
||||
"users", "fk_users_promo_group_id_promo_groups"
|
||||
)
|
||||
if not constraint_exists:
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT fk_users_promo_group_id_promo_groups
|
||||
FOREIGN KEY (promo_group_id)
|
||||
REFERENCES promo_groups(id)
|
||||
ON DELETE RESTRICT
|
||||
"""
|
||||
)
|
||||
)
|
||||
logger.info("Добавлен внешний ключ users -> promo_groups")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось добавить внешний ключ users.promo_group_id: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users ALTER COLUMN promo_group_id SET NOT NULL"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось сделать users.promo_group_id NOT NULL: {e}"
|
||||
)
|
||||
|
||||
elif db_type == "mysql":
|
||||
constraint_exists = await check_constraint_exists(
|
||||
"users", "fk_users_promo_group_id_promo_groups"
|
||||
)
|
||||
if not constraint_exists:
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT fk_users_promo_group_id_promo_groups
|
||||
FOREIGN KEY (promo_group_id)
|
||||
REFERENCES promo_groups(id)
|
||||
ON DELETE RESTRICT
|
||||
"""
|
||||
)
|
||||
)
|
||||
logger.info("Добавлен внешний ключ users -> promo_groups")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось добавить внешний ключ users.promo_group_id: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users MODIFY promo_group_id INT NOT NULL"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось сделать users.promo_group_id NOT NULL: {e}"
|
||||
)
|
||||
|
||||
logger.info("✅ Промо группы настроены")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка настройки промо групп: {e}")
|
||||
return False
|
||||
|
||||
async def add_welcome_text_is_enabled_column():
|
||||
column_exists = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
if column_exists:
|
||||
@@ -404,6 +772,49 @@ async def add_media_fields_to_broadcast_history():
|
||||
logger.error(f"Ошибка при добавлении полей медиа в broadcast_history: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_ticket_reply_block_columns():
|
||||
try:
|
||||
col_perm_exists = await check_column_exists('tickets', 'user_reply_block_permanent')
|
||||
col_until_exists = await check_column_exists('tickets', 'user_reply_block_until')
|
||||
|
||||
if col_perm_exists and col_until_exists:
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not col_perm_exists:
|
||||
if db_type == 'sqlite':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT 0 NOT NULL"
|
||||
elif db_type == 'postgresql':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
elif db_type == 'mysql':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для добавления user_reply_block_permanent: {db_type}")
|
||||
return False
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info("✅ Добавлена колонка tickets.user_reply_block_permanent")
|
||||
|
||||
if not col_until_exists:
|
||||
if db_type == 'sqlite':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL"
|
||||
elif db_type == 'postgresql':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until TIMESTAMP NULL"
|
||||
elif db_type == 'mysql':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL"
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для добавления user_reply_block_until: {db_type}")
|
||||
return False
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info("✅ Добавлена колонка tickets.user_reply_block_until")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонок блокировок в tickets: {e}")
|
||||
return False
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -688,7 +1099,21 @@ async def run_universal_migration():
|
||||
logger.info("✅ Медиа поля в broadcast_history готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением медиа полей")
|
||||
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ БЛОКИРОВКИ В TICKETS ===")
|
||||
tickets_block_cols_added = await add_ticket_reply_block_columns()
|
||||
if tickets_block_cols_added:
|
||||
logger.info("✅ Поля блокировок в tickets готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением полей блокировок в tickets")
|
||||
|
||||
logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===")
|
||||
promo_groups_ready = await ensure_promo_groups_setup()
|
||||
if promo_groups_ready:
|
||||
logger.info("✅ Промо группы готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с настройкой промо групп")
|
||||
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -756,10 +1181,12 @@ async def check_migration_status():
|
||||
"cryptobot_table": False,
|
||||
"user_messages_table": False,
|
||||
"welcome_texts_table": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
"broadcast_history_media_fields": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
"broadcast_history_media_fields": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False
|
||||
"subscription_conversions_table": False,
|
||||
"promo_groups_table": False,
|
||||
"users_promo_group_column": False
|
||||
}
|
||||
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
@@ -768,8 +1195,10 @@ async def check_migration_status():
|
||||
status["user_messages_table"] = await check_table_exists('user_messages')
|
||||
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
|
||||
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
||||
|
||||
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')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
@@ -797,9 +1226,11 @@ async def check_migration_status():
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок",
|
||||
"promo_groups_table": "Таблица промо-групп",
|
||||
"users_promo_group_column": "Колонка promo_group_id у пользователей"
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ from app.keyboards.admin import (
|
||||
get_admin_system_submenu_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.handlers.admin import support_settings as support_settings_handlers
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import clear_all_rules, get_rules_statistics
|
||||
from app.localization.texts import clear_rules_cache
|
||||
@@ -293,6 +294,8 @@ def register_handlers(dp: Dispatcher):
|
||||
show_system_submenu,
|
||||
F.data == "admin_submenu_system"
|
||||
)
|
||||
# Support settings module
|
||||
support_settings_handlers.register_handlers(dp)
|
||||
|
||||
dp.message.register(
|
||||
clear_rules_command,
|
||||
|
||||
+105
-21
@@ -9,7 +9,13 @@ from sqlalchemy import select, func, and_, or_
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
BroadcastHistory,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
@@ -260,14 +266,21 @@ async def select_broadcast_target(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
target = callback.data.split('_')[-1]
|
||||
|
||||
raw_target = callback.data[len("broadcast_"):]
|
||||
target_aliases = {
|
||||
"no_sub": "no",
|
||||
}
|
||||
target = target_aliases.get(raw_target, raw_target)
|
||||
|
||||
target_names = {
|
||||
"all": "Всем пользователям",
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"expiring": "С истекающей подпиской"
|
||||
"expiring": "С истекающей подпиской",
|
||||
"expired": "С истекшей подпиской",
|
||||
"active_zero": "Активная подписка, трафик 0 ГБ",
|
||||
"trial_zero": "Триальная подписка, трафик 0 ГБ",
|
||||
}
|
||||
|
||||
user_count = await get_target_users_count(db, target)
|
||||
@@ -817,22 +830,88 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
|
||||
async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
if target == "all":
|
||||
return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
elif target == "active":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial]
|
||||
elif target == "trial":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_trial]
|
||||
elif target == "no":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if not user.subscription or not user.subscription.is_active]
|
||||
elif target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
else:
|
||||
return []
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
|
||||
if target == "all":
|
||||
return users
|
||||
|
||||
if target == "active":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_active
|
||||
and not user.subscription.is_trial
|
||||
]
|
||||
|
||||
if target == "trial":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription and user.subscription.is_trial
|
||||
]
|
||||
|
||||
if target == "no":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if not user.subscription or not user.subscription.is_active
|
||||
]
|
||||
|
||||
if target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
|
||||
if target == "expired":
|
||||
now = datetime.utcnow()
|
||||
expired_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
}
|
||||
expired_users = []
|
||||
for user in users:
|
||||
subscription = user.subscription
|
||||
if subscription:
|
||||
if subscription.status in expired_statuses:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
if subscription.end_date <= now and not subscription.is_active:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
elif user.has_had_paid_subscription:
|
||||
expired_users.append(user)
|
||||
return expired_users
|
||||
|
||||
if target == "active_zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "trial_zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
@@ -956,7 +1035,12 @@ def get_target_name(target_type: str) -> str:
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"sub": "Без подписки",
|
||||
"expiring": "С истекающей подпиской",
|
||||
"expired": "С истекшей подпиской",
|
||||
"active_zero": "Активная подписка, трафик 0 ГБ",
|
||||
"trial_zero": "Триальная подписка, трафик 0 ГБ",
|
||||
"zero": "Подписка, трафик 0 ГБ",
|
||||
"custom_today": "Зарегистрированные сегодня",
|
||||
"custom_week": "Зарегистрированные за неделю",
|
||||
"custom_month": "Зарегистрированные за месяц",
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promo_group import (
|
||||
get_promo_groups_with_counts,
|
||||
get_promo_group_by_id,
|
||||
create_promo_group,
|
||||
update_promo_group,
|
||||
delete_promo_group,
|
||||
get_promo_group_members,
|
||||
count_promo_group_members,
|
||||
)
|
||||
from app.database.models import PromoGroup
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.keyboards.admin import (
|
||||
get_admin_pagination_keyboard,
|
||||
get_confirmation_keyboard,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_discount_line(texts, group) -> str:
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS",
|
||||
"Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
).format(
|
||||
servers=group.server_discount_percent,
|
||||
traffic=group.traffic_discount_percent,
|
||||
devices=group.device_discount_percent,
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_groups_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
groups = await get_promo_groups_with_counts(db)
|
||||
|
||||
total_members = sum(count for _, count in groups)
|
||||
header = texts.t("ADMIN_PROMO_GROUPS_TITLE", "💳 <b>Промогруппы</b>")
|
||||
|
||||
if groups:
|
||||
summary = texts.t(
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY",
|
||||
"Всего групп: {count}\nВсего участников: {members}",
|
||||
).format(count=len(groups), members=total_members)
|
||||
lines = [header, "", summary, ""]
|
||||
|
||||
keyboard_rows = []
|
||||
for group, member_count in groups:
|
||||
default_suffix = (
|
||||
texts.t("ADMIN_PROMO_GROUPS_DEFAULT_LABEL", " (базовая)")
|
||||
if group.is_default
|
||||
else ""
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
f"{'⭐' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
|
||||
_format_discount_line(texts, group),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
"",
|
||||
]
|
||||
)
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{'⭐' if group.is_default else '🎯'} {group.name}",
|
||||
callback_data=f"promo_group_manage_{group.id}",
|
||||
)
|
||||
])
|
||||
else:
|
||||
lines = [header, "", texts.t("ADMIN_PROMO_GROUPS_EMPTY", "Промогруппы не найдены.")]
|
||||
keyboard_rows = []
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_group_create")]
|
||||
)
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(line for line in lines if line is not None),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _get_group_or_alert(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
) -> Optional[PromoGroup]:
|
||||
group_id = int(callback.data.split("_")[-1])
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return None
|
||||
return group
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_group_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
member_count = await count_promo_group_members(db, group.id)
|
||||
|
||||
default_note = (
|
||||
"\n" + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
|
||||
if group.is_default
|
||||
else ""
|
||||
)
|
||||
|
||||
text = "\n".join(
|
||||
[
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
|
||||
"💳 <b>Промогруппа:</b> {name}",
|
||||
).format(name=group.name),
|
||||
_format_discount_line(texts, group),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
default_note,
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows = []
|
||||
if member_count > 0:
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PROMO_GROUP_MEMBERS_BUTTON", "👥 Участники"),
|
||||
callback_data=f"promo_group_members_{group.id}_page_1",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PROMO_GROUP_EDIT_BUTTON", "✏️ Изменить"),
|
||||
callback_data=f"promo_group_edit_{group.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if not group.is_default:
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PROMO_GROUP_DELETE_BUTTON", "🗑️ Удалить"),
|
||||
callback_data=f"promo_group_delete_{group.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text.strip(),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _validate_percent(value: str) -> int:
|
||||
percent = int(value)
|
||||
if percent < 0 or percent > 100:
|
||||
raise ValueError
|
||||
return percent
|
||||
|
||||
|
||||
async def _prompt_for_discount(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
prompt_key: str,
|
||||
default_text: str,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", "ru"))
|
||||
await message.answer(texts.t(prompt_key, default_text))
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_create_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
await state.set_state(AdminStates.creating_promo_group_name)
|
||||
await state.update_data(language=db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT", "Введите название новой промогруппы:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_create_group_name(message: types.Message, state: FSMContext):
|
||||
name = message.text.strip()
|
||||
if not name:
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
|
||||
return
|
||||
|
||||
await state.update_data(new_group_name=name)
|
||||
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT",
|
||||
"Введите скидку на трафик (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_create_group_traffic(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(new_group_traffic=value)
|
||||
await state.set_state(AdminStates.creating_promo_group_server_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT",
|
||||
"Введите скидку на серверы (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_create_group_servers(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(new_group_servers=value)
|
||||
await state.set_state(AdminStates.creating_promo_group_device_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT",
|
||||
"Введите скидку на устройства (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_create_group_devices(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
devices_discount = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
try:
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
data["new_group_name"],
|
||||
traffic_discount_percent=data["new_group_traffic"],
|
||||
server_discount_percent=data["new_group_servers"],
|
||||
device_discount_percent=devices_discount,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать промогруппу: {e}")
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_CREATED", "Промогруппа «{name}» создана.").format(
|
||||
name=group.name
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON",
|
||||
"↩️ К промогруппам",
|
||||
),
|
||||
callback_data="admin_promo_groups",
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
await state.set_state(AdminStates.editing_promo_group_name)
|
||||
await state.update_data(edit_group_id=group.id, language=db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
|
||||
"Введите новое название промогруппы (текущее: {name}):",
|
||||
).format(name=group.name),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group.id}")]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_edit_group_name(message: types.Message, state: FSMContext):
|
||||
name = message.text.strip()
|
||||
if not name:
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_name=name)
|
||||
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT",
|
||||
"Введите новую скидку на трафик (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_edit_group_traffic(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_traffic=value)
|
||||
await state.set_state(AdminStates.editing_promo_group_server_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT",
|
||||
"Введите новую скидку на серверы (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_edit_group_servers(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_servers=value)
|
||||
await state.set_state(AdminStates.editing_promo_group_device_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT",
|
||||
"Введите новую скидку на устройства (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_devices(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
devices_discount = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
group = await get_promo_group_by_id(db, data["edit_group_id"])
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_promo_group(
|
||||
db,
|
||||
group,
|
||||
name=data["edit_group_name"],
|
||||
traffic_discount_percent=data["edit_group_traffic"],
|
||||
server_discount_percent=data["edit_group_servers"],
|
||||
device_discount_percent=devices_discount,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_group_members(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
parts = callback.data.split("_")
|
||||
group_id = int(parts[3])
|
||||
page = int(parts[-1])
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
members = await get_promo_group_members(db, group_id, offset=offset, limit=limit)
|
||||
total_members = await count_promo_group_members(db, group_id)
|
||||
total_pages = max(1, (total_members + limit - 1) // limit)
|
||||
|
||||
title = texts.t(
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE",
|
||||
"👥 Участники группы {name}",
|
||||
).format(name=group.name)
|
||||
|
||||
if not members:
|
||||
body = texts.t("ADMIN_PROMO_GROUP_MEMBERS_EMPTY", "В этой группе пока нет участников.")
|
||||
else:
|
||||
lines = []
|
||||
for index, user in enumerate(members, start=offset + 1):
|
||||
username = f"@{user.username}" if user.username else "—"
|
||||
lines.append(
|
||||
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
)
|
||||
body = "\n".join(lines)
|
||||
|
||||
keyboard = []
|
||||
if total_pages > 1:
|
||||
pagination = get_admin_pagination_keyboard(
|
||||
page,
|
||||
total_pages,
|
||||
f"promo_group_members_{group_id}",
|
||||
f"promo_group_manage_{group_id}",
|
||||
db_user.language,
|
||||
)
|
||||
keyboard.extend(pagination.inline_keyboard)
|
||||
|
||||
keyboard.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group_id}")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{title}\n\n{body}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def request_delete_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if group.is_default:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
confirm_text = texts.t(
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM",
|
||||
"Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
|
||||
).format(name=group.name)
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text,
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
confirm_action=f"promo_group_delete_confirm_{group.id}",
|
||||
cancel_action=f"promo_group_manage_{group.id}",
|
||||
language=db_user.language,
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_promo_group_confirmed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
success = await delete_promo_group(db, group)
|
||||
if not success:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_PROMO_GROUP_DELETED", "Промогруппа «{name}» удалена.").format(name=group.name),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups")
|
||||
dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_"))
|
||||
dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create")
|
||||
dp.callback_query.register(start_edit_promo_group, F.data.startswith("promo_group_edit_"))
|
||||
dp.callback_query.register(
|
||||
request_delete_promo_group,
|
||||
F.data.startswith("promo_group_delete_")
|
||||
& ~F.data.startswith("promo_group_delete_confirm_"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
delete_promo_group_confirmed,
|
||||
F.data.startswith("promo_group_delete_confirm_"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_promo_group_members,
|
||||
F.data.regexp(r"^promo_group_members_\d+_page_\d+$"),
|
||||
)
|
||||
|
||||
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
|
||||
dp.message.register(
|
||||
process_create_group_traffic,
|
||||
AdminStates.creating_promo_group_traffic_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_servers,
|
||||
AdminStates.creating_promo_group_server_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_devices,
|
||||
AdminStates.creating_promo_group_device_discount,
|
||||
)
|
||||
|
||||
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
|
||||
dp.message.register(
|
||||
process_edit_group_traffic,
|
||||
AdminStates.editing_promo_group_traffic_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_servers,
|
||||
AdminStates.editing_promo_group_server_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_devices,
|
||||
AdminStates.editing_promo_group_device_discount,
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
import logging
|
||||
import re
|
||||
import html
|
||||
import contextlib
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.states import SupportSettingsStates
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
mode = SupportSettingsService.get_system_mode()
|
||||
menu_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
|
||||
rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=("✅ Пункт 'Техподдержка' в меню" if menu_enabled else "🚫 Пункт 'Техподдержка' в меню"),
|
||||
callback_data="admin_support_toggle_menu"
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text=("🔘 Тикеты" if mode == "tickets" else "⚪ Тикеты"), callback_data="admin_support_mode_tickets"),
|
||||
types.InlineKeyboardButton(text=("🔘 Контакт" if mode == "contact" else "⚪ Контакт"), callback_data="admin_support_mode_contact"),
|
||||
types.InlineKeyboardButton(text=("🔘 Оба" if mode == "both" else "⚪ Оба"), callback_data="admin_support_mode_both"),
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc")
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
|
||||
])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
desc = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
"🛟 <b>Настройки поддержки</b>\n\n" +
|
||||
"Режим работы и видимость в меню. Ниже текущее описание меню поддержки:\n\n" +
|
||||
desc,
|
||||
reply_markup=_get_support_settings_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_support_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current = SupportSettingsService.is_support_menu_enabled()
|
||||
SupportSettingsService.set_support_menu_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("tickets")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_contact(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("contact")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_both(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("both")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
# plain text for display-only code block
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
|
||||
kb_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(text="📨 Прислать текст", callback_data="admin_support_send_desc")
|
||||
])
|
||||
# Подготовим блок контакта (отдельным инлайном)
|
||||
from app.config import settings
|
||||
support_contact_display = settings.get_support_contact_display()
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")
|
||||
])
|
||||
|
||||
text_parts = [
|
||||
"📝 <b>Редактирование описания поддержки</b>",
|
||||
"",
|
||||
"Текущее описание:",
|
||||
"",
|
||||
f"<code>{html.escape(current_desc_plain)}</code>",
|
||||
]
|
||||
if support_contact_display:
|
||||
text_parts += [
|
||||
"",
|
||||
"<b>Контакт для режима \u00abКонтакт\u00bb</b>",
|
||||
f"<code>{html.escape(support_contact_display)}</code>",
|
||||
"",
|
||||
"Добавьте в описание при необходимости.",
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_parts),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=kb_rows),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(SupportSettingsStates.waiting_for_desc)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
new_text = message.html_text or message.text
|
||||
SupportSettingsService.set_support_info_text(db_user.language, new_text)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer("✅ Описание обновлено.", reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# send plain text for easy copying
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
# attach delete button to the sent message
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
if len(current_desc_plain) <= 4000:
|
||||
await callback.message.answer(current_desc_plain, reply_markup=markup)
|
||||
else:
|
||||
# split long messages (attach delete only to the last chunk)
|
||||
chunk = 0
|
||||
while chunk < len(current_desc_plain):
|
||||
next_chunk = current_desc_plain[chunk:chunk+4000]
|
||||
is_last = (chunk + 4000) >= len(current_desc_plain)
|
||||
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
|
||||
chunk += 4000
|
||||
await callback.answer("Текст отправлен ниже")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
try:
|
||||
await callback.message.delete()
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await callback.answer("Сообщение удалено")
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_support_settings, F.data == "admin_support_settings")
|
||||
dp.callback_query.register(toggle_support_menu, F.data == "admin_support_toggle_menu")
|
||||
dp.callback_query.register(set_mode_tickets, F.data == "admin_support_mode_tickets")
|
||||
dp.callback_query.register(set_mode_contact, F.data == "admin_support_mode_contact")
|
||||
dp.callback_query.register(set_mode_both, F.data == "admin_support_mode_both")
|
||||
dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc")
|
||||
dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc")
|
||||
dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg")
|
||||
dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc)
|
||||
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
|
||||
from app.database.models import User, Ticket, TicketStatus
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
from app.states import TicketStates, AdminTicketStates
|
||||
from app.keyboards.inline import (
|
||||
get_admin_tickets_keyboard,
|
||||
get_admin_ticket_view_keyboard,
|
||||
get_admin_ticket_reply_cancel_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.pagination import paginate_list, get_pagination_info
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.config import settings
|
||||
from app.utils.cache import RateLimitCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def show_admin_tickets(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Показать все тикеты для админов"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Определяем текущую страницу и scope
|
||||
current_page = 1
|
||||
scope = "open"
|
||||
data_str = callback.data
|
||||
if data_str == "admin_tickets_scope_open":
|
||||
scope = "open"
|
||||
elif data_str == "admin_tickets_scope_closed":
|
||||
scope = "closed"
|
||||
elif data_str.startswith("admin_tickets_page_"):
|
||||
try:
|
||||
parts = data_str.split("_")
|
||||
# format: admin_tickets_page_{scope}_{page}
|
||||
if len(parts) >= 5:
|
||||
scope = parts[3]
|
||||
current_page = int(parts[4])
|
||||
else:
|
||||
current_page = int(data_str.replace("admin_tickets_page_", ""))
|
||||
except ValueError:
|
||||
current_page = 1
|
||||
statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value] if scope == "open" else [TicketStatus.CLOSED.value]
|
||||
page_size = 10
|
||||
# total count for proper pagination
|
||||
total_count = await TicketCRUD.count_tickets_by_statuses(db, statuses)
|
||||
total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count > 0 else 1
|
||||
if current_page > total_pages:
|
||||
current_page = total_pages
|
||||
offset = (current_page - 1) * page_size
|
||||
tickets = await TicketCRUD.get_tickets_by_statuses(db, statuses=statuses, limit=page_size, offset=offset)
|
||||
|
||||
# Даже если тикетов нет, показываем переключатели разделов
|
||||
|
||||
# Формируем данные для клавиатуры
|
||||
ticket_data = []
|
||||
for ticket in tickets:
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
ticket_data.append({
|
||||
'id': ticket.id,
|
||||
'title': ticket.title,
|
||||
'status_emoji': ticket.status_emoji,
|
||||
'priority_emoji': ticket.priority_emoji,
|
||||
'user_name': user_name,
|
||||
'is_closed': ticket.is_closed,
|
||||
'locked_emoji': ("🔒" if ticket.is_user_reply_blocked else "")
|
||||
})
|
||||
|
||||
# Итоговые страницы уже посчитаны выше
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_TICKETS_TITLE", "🎫 Все тикеты поддержки:"),
|
||||
reply_markup=get_admin_tickets_keyboard(ticket_data, current_page=current_page, total_pages=total_pages, language=db_user.language, scope=scope)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def view_admin_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: Optional[FSMContext] = None,
|
||||
ticket_id: Optional[int] = None
|
||||
):
|
||||
"""Показать детали тикета для админа"""
|
||||
if ticket_id is None:
|
||||
try:
|
||||
ticket_id = int((callback.data or "").split("_")[-1])
|
||||
except (ValueError, AttributeError):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_NOT_FOUND", "Тикет не найден."),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
if state is None:
|
||||
state = FSMContext(callback.bot, callback.from_user.id)
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True)
|
||||
|
||||
if not ticket:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_NOT_FOUND", "Тикет не найден."),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Формируем текст тикета
|
||||
status_text = {
|
||||
TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"),
|
||||
TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"),
|
||||
TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"),
|
||||
TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании")
|
||||
}.get(ticket.status, ticket.status)
|
||||
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
|
||||
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"📝 Заголовок: {ticket.title}\n"
|
||||
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
ticket_text += f"🔄 Обновлен: {ticket.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
if ticket.is_user_reply_blocked:
|
||||
if ticket.user_reply_block_permanent:
|
||||
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
|
||||
elif ticket.user_reply_block_until:
|
||||
ticket_text += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
|
||||
if ticket.messages:
|
||||
ticket_text += f"💬 Сообщения ({len(ticket.messages)}):\n\n"
|
||||
|
||||
for msg in ticket.messages:
|
||||
sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка"
|
||||
ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
|
||||
ticket_text += f"{msg.message_text}\n\n"
|
||||
if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo":
|
||||
ticket_text += "📎 Вложение: фото\n\n"
|
||||
|
||||
# Добавим кнопку "Вложения", если есть фото
|
||||
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or [])
|
||||
keyboard = get_admin_ticket_view_keyboard(
|
||||
ticket_id,
|
||||
ticket.is_closed,
|
||||
db_user.language
|
||||
)
|
||||
if has_photos:
|
||||
try:
|
||||
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Сначала пробуем отредактировать; если не вышло — удалим и отправим новое
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
ticket_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
ticket_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
# сохраняем id для дальнейших действий (ответ/статусы)
|
||||
if state:
|
||||
await state.update_data(ticket_id=ticket_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def reply_to_admin_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
"""Начать ответ на тикет от админа"""
|
||||
ticket_id = int(callback.data.replace("admin_reply_ticket_", ""))
|
||||
|
||||
await state.update_data(ticket_id=ticket_id, reply_mode=True)
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_TICKET_REPLY_INPUT", "Введите ответ от поддержки:"),
|
||||
reply_markup=get_admin_ticket_reply_cancel_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(AdminTicketStates.waiting_for_reply)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_admin_ticket_reply(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
# Проверяем, что пользователь в правильном состоянии
|
||||
current_state = await state.get_state()
|
||||
if current_state != AdminTicketStates.waiting_for_reply:
|
||||
return
|
||||
|
||||
# Анти-спам: одно сообщение за короткое окно по конкретному тикету
|
||||
try:
|
||||
data_rl = await state.get_data()
|
||||
rl_ticket_id = data_rl.get("ticket_id") or "admin_reply"
|
||||
limited = await RateLimitCache.is_rate_limited(db_user.id, f"admin_ticket_reply_{rl_ticket_id}", limit=1, window=2)
|
||||
if limited:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
data_rl = await state.get_data()
|
||||
last_ts = data_rl.get("admin_rl_ts_reply")
|
||||
now_ts = time.time()
|
||||
if last_ts and (now_ts - float(last_ts)) < 2:
|
||||
return
|
||||
await state.update_data(admin_rl_ts_reply=now_ts)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
"""Обработать ответ админа на тикет"""
|
||||
# Поддержка фото вложений в ответе админа
|
||||
reply_text = (message.text or message.caption or "").strip()
|
||||
if len(reply_text) > 400:
|
||||
reply_text = reply_text[:400]
|
||||
media_type = None
|
||||
media_file_id = None
|
||||
media_caption = None
|
||||
if message.photo:
|
||||
media_type = "photo"
|
||||
media_file_id = message.photo[-1].file_id
|
||||
media_caption = message.caption
|
||||
|
||||
if len(reply_text) < 1 and not media_file_id:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("TICKET_REPLY_TOO_SHORT", "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:")
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
ticket_id = data.get("ticket_id")
|
||||
try:
|
||||
ticket_id = int(ticket_id) if ticket_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
ticket_id = None
|
||||
|
||||
if not ticket_id:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("TICKET_REPLY_ERROR", "Ошибка: не найден ID тикета.")
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
# Если это режим ввода длительности блокировки
|
||||
if not data.get("reply_mode"):
|
||||
try:
|
||||
minutes = int(reply_text)
|
||||
minutes = max(1, min(60*24*365, minutes))
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите целое число минут")
|
||||
return
|
||||
until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until)
|
||||
if ok:
|
||||
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
|
||||
else:
|
||||
await message.answer("❌ Ошибка блокировки")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Обычный режим ответа админа
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("TICKET_NOT_FOUND", "Тикет не найден.")
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Добавляем сообщение от админа (внутри add_message статус станет ANSWERED)
|
||||
await TicketMessageCRUD.add_message(
|
||||
db,
|
||||
ticket_id,
|
||||
db_user.id,
|
||||
reply_text,
|
||||
is_from_admin=True,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await message.answer(
|
||||
texts.t("ADMIN_TICKET_REPLY_SENT", "✅ Ответ отправлен!"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"),
|
||||
callback_data=f"admin_view_ticket_{ticket_id}"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_TICKETS", "⬅️ К тикетам"),
|
||||
callback_data="admin_tickets"
|
||||
)]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
# Уведомляем пользователя о новом ответе
|
||||
await notify_user_about_ticket_reply(message.bot, ticket, reply_text, db)
|
||||
# Админ-уведомления о ответе в тикет отключены по требованию
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin ticket reply: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("TICKET_REPLY_ERROR", "❌ Произошла ошибка при отправке ответа. Попробуйте позже.")
|
||||
)
|
||||
|
||||
|
||||
async def mark_ticket_as_answered(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
"""Отметить тикет как отвеченный"""
|
||||
ticket_id = int(callback.data.replace("admin_mark_answered_", ""))
|
||||
|
||||
try:
|
||||
success = await TicketCRUD.update_ticket_status(
|
||||
db, ticket_id, TicketStatus.ANSWERED.value
|
||||
)
|
||||
|
||||
if success:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_MARKED_ANSWERED", "✅ Тикет отмечен как отвеченный."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
# Обновляем сообщение
|
||||
await view_admin_ticket(callback, db_user, db, state)
|
||||
else:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking ticket as answered: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
async def close_admin_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Закрыть тикет админом"""
|
||||
ticket_id = int(callback.data.replace("admin_close_ticket_", ""))
|
||||
|
||||
try:
|
||||
success = await TicketCRUD.close_ticket(db, ticket_id)
|
||||
|
||||
if success:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_CLOSED", "✅ Тикет закрыт."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
# Обновляем inline-клавиатуру в текущем сообщении без кнопок действий
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=get_admin_ticket_view_keyboard(ticket_id, True, db_user.language)
|
||||
)
|
||||
else:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_CLOSE_ERROR", "❌ Ошибка при закрытии тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing admin ticket: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_CLOSE_ERROR", "❌ Ошибка при закрытии тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
async def cancel_admin_ticket_reply(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
"""Отменить ответ админа на тикет"""
|
||||
await state.clear()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("TICKET_REPLY_CANCELLED", "Ответ отменен."),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_TICKETS", "⬅️ К тикетам"),
|
||||
callback_data="admin_tickets"
|
||||
)]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def block_user_in_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
ticket_id = int(callback.data.replace("admin_block_user_ticket_", ""))
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ENTER_BLOCK_MINUTES", "Введите количество минут для блокировки пользователя (например, 15):"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"),
|
||||
callback_data="cancel_admin_ticket_reply"
|
||||
)]
|
||||
])
|
||||
)
|
||||
await state.update_data(ticket_id=ticket_id)
|
||||
await state.set_state(AdminTicketStates.waiting_for_block_duration)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_admin_block_duration_input(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
# Проверяем состояние
|
||||
current_state = await state.get_state()
|
||||
if current_state != AdminTicketStates.waiting_for_block_duration:
|
||||
return
|
||||
|
||||
reply_text = message.text.strip()
|
||||
if len(reply_text) < 1:
|
||||
await message.answer("❌ Введите целое число минут")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
ticket_id = data.get("ticket_id")
|
||||
try:
|
||||
minutes = int(reply_text)
|
||||
minutes = max(1, min(60*24*365, minutes)) # максимум 1 год
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите целое число минут")
|
||||
return
|
||||
|
||||
if not ticket_id:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(texts.t("TICKET_REPLY_ERROR", "Ошибка: не найден ID тикета."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until)
|
||||
if ok:
|
||||
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
|
||||
else:
|
||||
await message.answer("❌ Ошибка блокировки")
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"✅ Блокировка установлена. Откройте тикет заново для обновления состояния.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text="👁️ Посмотреть тикет", callback_data=f"admin_view_ticket_{ticket_id}")]])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting block duration: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(texts.t("TICKET_REPLY_ERROR", "❌ Произошла ошибка. Попробуйте позже."))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def unblock_user_in_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", ""))
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=None)
|
||||
if ok:
|
||||
await callback.answer("✅ Блок снят")
|
||||
await view_admin_ticket(callback, db_user, db, state)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка", show_alert=True)
|
||||
|
||||
|
||||
async def block_user_permanently(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
ticket_id = int(callback.data.replace("admin_block_user_perm_ticket_", ""))
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=True, until=None)
|
||||
if ok:
|
||||
await callback.answer("✅ Пользователь заблокирован навсегда")
|
||||
await view_admin_ticket(callback, db_user, db, state)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка", show_alert=True)
|
||||
|
||||
|
||||
async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: str, db: AsyncSession):
|
||||
"""Уведомить пользователя о новом ответе в тикете"""
|
||||
try:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
# Получаем тикет с пользователем
|
||||
ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True)
|
||||
if not ticket_with_user or not ticket_with_user.user:
|
||||
logger.error(f"User not found for ticket #{ticket.id}")
|
||||
return
|
||||
|
||||
texts = get_texts(ticket_with_user.user.language)
|
||||
|
||||
# Формируем уведомление
|
||||
base_text = texts.t(
|
||||
"TICKET_REPLY_NOTIFICATION",
|
||||
"🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:"
|
||||
).format(
|
||||
ticket_id=ticket.id,
|
||||
reply_preview=reply_text[:100] + "..." if len(reply_text) > 100 else reply_text
|
||||
)
|
||||
# Если было фото в последнем ответе админа — отправим как фото
|
||||
last_message = await TicketMessageCRUD.get_last_message(db, ticket.id)
|
||||
if last_message and last_message.has_media and last_message.media_type == "photo" and last_message.is_from_admin:
|
||||
caption = base_text
|
||||
try:
|
||||
await bot.send_photo(
|
||||
chat_id=ticket_with_user.user.telegram_id,
|
||||
photo=last_message.media_file_id,
|
||||
caption=caption,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
|
||||
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить фото-уведомление: {e}")
|
||||
# Фоллбек: текстовое уведомление
|
||||
await bot.send_message(
|
||||
chat_id=ticket_with_user.user.telegram_id,
|
||||
text=base_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
|
||||
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
|
||||
])
|
||||
)
|
||||
|
||||
logger.info(f"Ticket #{ticket.id} reply notification sent to user {ticket_with_user.user.telegram_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error notifying user about ticket reply: {e}")
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
"""Регистрация админских обработчиков тикетов"""
|
||||
|
||||
# Просмотр тикетов
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets")
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open")
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed")
|
||||
|
||||
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
|
||||
|
||||
# Ответы на тикеты
|
||||
dp.callback_query.register(
|
||||
reply_to_admin_ticket,
|
||||
F.data.startswith("admin_reply_ticket_")
|
||||
)
|
||||
|
||||
dp.message.register(handle_admin_ticket_reply, AdminTicketStates.waiting_for_reply)
|
||||
dp.message.register(handle_admin_block_duration_input, AdminTicketStates.waiting_for_block_duration)
|
||||
|
||||
# Управление статусами: явная кнопка больше не используется (статус меняется автоматически)
|
||||
|
||||
dp.callback_query.register(
|
||||
close_admin_ticket,
|
||||
F.data.startswith("admin_close_ticket_")
|
||||
)
|
||||
dp.callback_query.register(block_user_in_ticket, F.data.startswith("admin_block_user_ticket_"))
|
||||
dp.callback_query.register(unblock_user_in_ticket, F.data.startswith("admin_unblock_user_ticket_"))
|
||||
dp.callback_query.register(block_user_permanently, F.data.startswith("admin_block_user_perm_ticket_"))
|
||||
|
||||
# Отмена операций
|
||||
dp.callback_query.register(
|
||||
cancel_admin_ticket_reply,
|
||||
F.data == "cancel_admin_ticket_reply"
|
||||
)
|
||||
|
||||
# Пагинация админских тикетов
|
||||
dp.callback_query.register(show_admin_tickets, F.data.startswith("admin_tickets_page_"))
|
||||
|
||||
# Управление компоновкой ответа — (отключено)
|
||||
|
||||
# Вложения в тикете (админ)
|
||||
async def send_admin_ticket_attachments(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
try:
|
||||
ticket_id = int(callback.data.replace("admin_ticket_attachments_", ""))
|
||||
except ValueError:
|
||||
await callback.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден."), show_alert=True)
|
||||
return
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True)
|
||||
if not ticket:
|
||||
await callback.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден."), show_alert=True)
|
||||
return
|
||||
photos = [m.media_file_id for m in ticket.messages if getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" and m.media_file_id]
|
||||
if not photos:
|
||||
await callback.answer(texts.t("NO_ATTACHMENTS", "Вложений нет."), show_alert=True)
|
||||
return
|
||||
from aiogram.types import InputMediaPhoto
|
||||
chunks = [photos[i:i+10] for i in range(0, len(photos), 10)]
|
||||
last_group_message = None
|
||||
for chunk in chunks:
|
||||
media = [InputMediaPhoto(media=pid) for pid in chunk]
|
||||
try:
|
||||
messages = await callback.message.bot.send_media_group(chat_id=callback.from_user.id, media=media)
|
||||
if messages:
|
||||
last_group_message = messages[-1]
|
||||
except Exception:
|
||||
pass
|
||||
# После отправки добавим кнопку удалить под последним сообщением группы
|
||||
if last_group_message:
|
||||
try:
|
||||
kb = types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data=f"admin_delete_message_{last_group_message.message_id}")]])
|
||||
await callback.message.bot.send_message(chat_id=callback.from_user.id, text=texts.t("ATTACHMENTS_SENT", "Вложения отправлены."), reply_markup=kb)
|
||||
except Exception:
|
||||
await callback.answer(texts.t("ATTACHMENTS_SENT", "Вложения отправлены."))
|
||||
else:
|
||||
await callback.answer(texts.t("ATTACHMENTS_SENT", "Вложения отправлены."))
|
||||
|
||||
dp.callback_query.register(send_admin_ticket_attachments, F.data.startswith("admin_ticket_attachments_"))
|
||||
|
||||
async def admin_delete_message(
|
||||
callback: types.CallbackQuery
|
||||
):
|
||||
try:
|
||||
msg_id = int(callback.data.replace("admin_delete_message_", ""))
|
||||
except ValueError:
|
||||
await callback.answer("❌")
|
||||
return
|
||||
try:
|
||||
await callback.message.bot.delete_message(chat_id=callback.from_user.id, message_id=msg_id)
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.answer("✅")
|
||||
|
||||
dp.callback_query.register(admin_delete_message, F.data.startswith("admin_delete_message_"))
|
||||
|
||||
+557
-185
@@ -8,13 +8,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.campaign import (
|
||||
get_campaign_registration_by_user,
|
||||
get_campaign_statistics,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_users_keyboard, get_user_management_keyboard,
|
||||
get_admin_pagination_keyboard, get_confirmation_keyboard
|
||||
get_admin_pagination_keyboard, get_confirmation_keyboard,
|
||||
get_admin_users_filters_keyboard, get_user_promo_group_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
@@ -58,15 +64,36 @@ async def show_users_menu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_filters(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
|
||||
text = "⚙️ <b>Фильтры пользователей</b>\n\nВыберите фильтр для отображения пользователей:"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_users_filters_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
# Сбрасываем состояние, так как мы в обычном списке
|
||||
await state.set_state(None)
|
||||
|
||||
user_service = UserService()
|
||||
users_data = await user_service.get_users_page(db, page=page, limit=10)
|
||||
|
||||
@@ -152,20 +179,139 @@ async def show_users_list(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_balance(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
# Устанавливаем состояние, чтобы отслеживать, откуда пришел пользователь
|
||||
await state.set_state(AdminStates.viewing_user_from_balance_list)
|
||||
|
||||
user_service = UserService()
|
||||
users_data = await user_service.get_users_page(db, page=page, limit=10, order_by_balance=True)
|
||||
|
||||
if not users_data["users"]:
|
||||
await callback.message.edit_text(
|
||||
"👥 Пользователи не найдены",
|
||||
reply_markup=get_admin_users_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"👥 <b>Список пользователей по балансу</b> (стр. {page}/{users_data['total_pages']})\n\n"
|
||||
text += "Нажмите на пользователя для управления:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in users_data["users"]:
|
||||
if user.status == UserStatus.ACTIVE.value:
|
||||
status_emoji = "✅"
|
||||
elif user.status == UserStatus.BLOCKED.value:
|
||||
status_emoji = "🚫"
|
||||
else:
|
||||
status_emoji = "🗑️"
|
||||
|
||||
subscription_emoji = ""
|
||||
if user.subscription:
|
||||
if user.subscription.is_trial:
|
||||
subscription_emoji = "🎁"
|
||||
elif user.subscription.is_active:
|
||||
subscription_emoji = "💎"
|
||||
else:
|
||||
subscription_emoji = "⏰"
|
||||
else:
|
||||
subscription_emoji = "❌"
|
||||
|
||||
button_text = f"{status_emoji} {subscription_emoji} {user.full_name}"
|
||||
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
|
||||
|
||||
# Добавляем дату окончания подписки, если есть подписка
|
||||
if user.subscription and user.subscription.end_date:
|
||||
days_left = (user.subscription.end_date - datetime.utcnow()).days
|
||||
button_text += f" | 📅 {days_left}д"
|
||||
|
||||
if len(button_text) > 60:
|
||||
short_name = user.full_name
|
||||
if len(short_name) > 20:
|
||||
short_name = short_name[:17] + "..."
|
||||
|
||||
button_text = f"{status_emoji} {subscription_emoji} {short_name}"
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if users_data["total_pages"] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data["current_page"],
|
||||
users_data["total_pages"],
|
||||
"admin_users_balance_list",
|
||||
"admin_users",
|
||||
db_user.language
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"),
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_list_pagination_fixed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list(callback, db_user, db, page)
|
||||
await show_users_list(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка парсинга номера страницы: {e}")
|
||||
await show_users_list(callback, db_user, db, 1)
|
||||
await show_users_list(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_balance_list_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_balance(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка парсинга номера страницы: {e}")
|
||||
await show_users_list_by_balance(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -271,6 +417,148 @@ async def show_users_statistics(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_user_subscription_overview(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return False
|
||||
|
||||
user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
text = "📱 <b>Подписка и настройки пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
if subscription:
|
||||
status_emoji = "✅" if subscription.is_active else "❌"
|
||||
type_emoji = "🎁" if subscription.is_trial else "💎"
|
||||
|
||||
traffic_display = f"{subscription.traffic_used_gb:.1f}/"
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
traffic_display += "♾️ ГБ"
|
||||
else:
|
||||
traffic_display += f"{subscription.traffic_limit_gb} ГБ"
|
||||
|
||||
text += f"<b>Статус:</b> {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n"
|
||||
text += f"<b>Тип:</b> {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n"
|
||||
text += f"<b>Начало:</b> {format_datetime(subscription.start_date)}\n"
|
||||
text += f"<b>Окончание:</b> {format_datetime(subscription.end_date)}\n"
|
||||
text += f"<b>Трафик:</b> {traffic_display}\n"
|
||||
text += f"<b>Устройства:</b> {subscription.device_limit}\n"
|
||||
|
||||
if subscription.is_active:
|
||||
days_left = (subscription.end_date - datetime.utcnow()).days
|
||||
text += f"<b>Осталось дней:</b> {days_left}\n"
|
||||
|
||||
current_squads = subscription.connected_squads or []
|
||||
if current_squads:
|
||||
text += "\n<b>Подключенные серверы:</b>\n"
|
||||
for squad_uuid in current_squads:
|
||||
try:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server:
|
||||
text += f"• {server.display_name}\n"
|
||||
else:
|
||||
text += f"• {squad_uuid[:8]}... (неизвестный)\n"
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сервера {squad_uuid}: {e}")
|
||||
text += f"• {squad_uuid[:8]}... (ошибка загрузки)\n"
|
||||
else:
|
||||
text += "\n<b>Подключенные серверы:</b> отсутствуют\n"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⏰ Продлить",
|
||||
callback_data=f"admin_sub_extend_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="💳 Купить подписку",
|
||||
callback_data=f"admin_sub_buy_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Тип подписки",
|
||||
callback_data=f"admin_sub_change_type_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="📊 Добавить трафик",
|
||||
callback_data=f"admin_sub_traffic_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🌍 Сменить сервер",
|
||||
callback_data=f"admin_user_change_server_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="📱 Устройства",
|
||||
callback_data=f"admin_user_devices_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🛠️ Лимит трафика",
|
||||
callback_data=f"admin_user_traffic_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Сбросить устройства",
|
||||
callback_data=f"admin_user_reset_devices_{user_id}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
if subscription.is_active:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🚫 Деактивировать",
|
||||
callback_data=f"admin_sub_deactivate_{user_id}"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Активировать",
|
||||
callback_data=f"admin_sub_activate_{user_id}"
|
||||
)
|
||||
])
|
||||
else:
|
||||
text += "❌ <b>Подписка отсутствует</b>\n\n"
|
||||
text += "Пользователь еще не активировал подписку."
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎁 Выдать триал",
|
||||
callback_data=f"admin_sub_grant_trial_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="💎 Выдать подписку",
|
||||
callback_data=f"admin_sub_grant_{user_id}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_subscription(
|
||||
@@ -278,101 +566,11 @@ async def show_user_subscription(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
text = f"📱 <b>Подписка пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
if subscription:
|
||||
status_emoji = "✅" if subscription.is_active else "❌"
|
||||
type_emoji = "🎁" if subscription.is_trial else "💎"
|
||||
|
||||
text += f"<b>Статус:</b> {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n"
|
||||
text += f"<b>Тип:</b> {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n"
|
||||
text += f"<b>Начало:</b> {format_datetime(subscription.start_date)}\n"
|
||||
text += f"<b>Окончание:</b> {format_datetime(subscription.end_date)}\n"
|
||||
text += f"<b>Трафик:</b> {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n"
|
||||
text += f"<b>Устройства:</b> {subscription.device_limit}\n"
|
||||
text += f"<b>Подключенных устройств:</b> {subscription.device_limit}\n"
|
||||
|
||||
if subscription.is_active:
|
||||
days_left = (subscription.end_date - datetime.utcnow()).days
|
||||
text += f"<b>Осталось дней:</b> {days_left}\n"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⏰ Продлить",
|
||||
callback_data=f"admin_sub_extend_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="📊 Трафик",
|
||||
callback_data=f"admin_sub_traffic_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Тип подписки",
|
||||
callback_data=f"admin_sub_change_type_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="💳 Купить подписку",
|
||||
callback_data=f"admin_sub_buy_{user_id}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
if subscription.is_active:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="🚫 Деактивировать",
|
||||
callback_data=f"admin_sub_deactivate_{user_id}"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Активировать",
|
||||
callback_data=f"admin_sub_activate_{user_id}"
|
||||
)
|
||||
])
|
||||
else:
|
||||
text += "❌ <b>Подписка отсутствует</b>\n\n"
|
||||
text += "Пользователь еще не активировал подписку."
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎁 Выдать триал",
|
||||
callback_data=f"admin_sub_grant_trial_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="💎 Выдать подписку",
|
||||
callback_data=f"admin_sub_grant_{user_id}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
if await _render_user_subscription_overview(callback, db, user_id):
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -564,11 +762,18 @@ async def process_user_search(
|
||||
async def show_user_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
# Проверяем, откуда пришел пользователь
|
||||
back_callback = "admin_users_list"
|
||||
|
||||
# Если callback_data содержит информацию о том, что мы пришли из списка по балансу
|
||||
# В реальности это сложно определить, поэтому будем использовать состояние
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
@@ -620,14 +825,142 @@ async def show_user_management(
|
||||
"""
|
||||
else:
|
||||
text += "\n<b>Подписка:</b> Отсутствует"
|
||||
|
||||
if user.promo_group:
|
||||
promo_group = user.promo_group
|
||||
text += f"""
|
||||
|
||||
<b>Промогруппа:</b>
|
||||
• Название: {promo_group.name}
|
||||
• Скидка на сервера: {promo_group.server_discount_percent}%
|
||||
• Скидка на трафик: {promo_group.traffic_discount_percent}%
|
||||
• Скидка на устройства: {promo_group.device_discount_percent}%
|
||||
"""
|
||||
else:
|
||||
text += "\n<b>Промогруппа:</b> Не назначена"
|
||||
|
||||
# Проверяем состояние, чтобы определить, откуда пришел пользователь
|
||||
current_state = await state.get_state()
|
||||
if current_state == AdminStates.viewing_user_from_balance_list:
|
||||
back_callback = "admin_users_balance_filter"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language)
|
||||
reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_user_promo_group(
|
||||
message: types.Message,
|
||||
language: str,
|
||||
user: User,
|
||||
promo_groups: list
|
||||
) -> None:
|
||||
texts = get_texts(language)
|
||||
|
||||
current_group = user.promo_group
|
||||
|
||||
if current_group:
|
||||
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT.format(name=current_group.name)
|
||||
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format(
|
||||
servers=current_group.server_discount_percent,
|
||||
traffic=current_group.traffic_discount_percent,
|
||||
devices=current_group.device_discount_percent,
|
||||
)
|
||||
current_group_id = current_group.id
|
||||
else:
|
||||
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE
|
||||
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE
|
||||
current_group_id = None
|
||||
|
||||
text = (
|
||||
f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n"
|
||||
f"{current_line}\n"
|
||||
f"{discount_line}\n\n"
|
||||
f"{texts.ADMIN_USER_PROMO_GROUP_SELECT}"
|
||||
)
|
||||
|
||||
await message.edit_text(
|
||||
text,
|
||||
reply_markup=get_user_promo_group_keyboard(
|
||||
promo_groups,
|
||||
user.id,
|
||||
current_group_id,
|
||||
language
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
promo_groups = await get_promo_groups_with_counts(db)
|
||||
if not promo_groups:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ADMIN_PROMO_GROUPS_EMPTY, show_alert=True)
|
||||
return
|
||||
|
||||
await _render_user_promo_group(callback.message, db_user.language, user, promo_groups)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_user_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[-2])
|
||||
group_id = int(parts[-1])
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if user.promo_group_id == group_id:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ALREADY, show_alert=True)
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
success, updated_user, new_group = await user_service.update_user_promo_group(
|
||||
db,
|
||||
user_id,
|
||||
group_id
|
||||
)
|
||||
|
||||
if not success or not updated_user or not new_group:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
|
||||
return
|
||||
|
||||
promo_groups = await get_promo_groups_with_counts(db)
|
||||
|
||||
await _render_user_promo_group(callback.message, db_user.language, updated_user, promo_groups)
|
||||
await callback.answer(
|
||||
texts.ADMIN_USER_PROMO_GROUP_UPDATED.format(name=new_group.name),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_balance_edit(
|
||||
@@ -885,6 +1218,10 @@ async def show_user_statistics(
|
||||
subscription = profile["subscription"]
|
||||
|
||||
referral_stats = await get_detailed_referral_stats(db, user.id)
|
||||
campaign_registration = await get_campaign_registration_by_user(db, user.id)
|
||||
campaign_stats = None
|
||||
if campaign_registration:
|
||||
campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id)
|
||||
|
||||
text = f"📊 <b>Статистика пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
@@ -907,17 +1244,80 @@ async def show_user_statistics(
|
||||
text += f"• Отсутствует\n"
|
||||
|
||||
text += f"\n<b>Реферальная программа:</b>\n"
|
||||
|
||||
|
||||
if user.referred_by_id:
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if referrer:
|
||||
text += f"• Пришел по реферальной ссылке от <b>{referrer.full_name}</b>\n"
|
||||
else:
|
||||
text += f"• Пришел по реферальной ссылке (реферер не найден)\n"
|
||||
text += "• Пришел по реферальной ссылке (реферер не найден)\n"
|
||||
if campaign_registration and campaign_registration.campaign:
|
||||
text += (
|
||||
"• Дополнительно зарегистрирован через кампанию "
|
||||
f"<b>{campaign_registration.campaign.name}</b>\n"
|
||||
)
|
||||
elif campaign_registration and campaign_registration.campaign:
|
||||
text += (
|
||||
"• Регистрация через рекламную кампанию "
|
||||
f"<b>{campaign_registration.campaign.name}</b>\n"
|
||||
)
|
||||
if campaign_registration.created_at:
|
||||
text += (
|
||||
"• Дата регистрации по кампании: "
|
||||
f"{campaign_registration.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
)
|
||||
else:
|
||||
text += f"• Прямая регистрация\n"
|
||||
|
||||
text += "• Прямая регистрация\n"
|
||||
|
||||
text += f"• Реферальный код: <code>{user.referral_code}</code>\n\n"
|
||||
|
||||
if campaign_registration and campaign_registration.campaign and campaign_stats:
|
||||
text += "<b>Рекламная кампания:</b>\n"
|
||||
text += (
|
||||
"• Название: "
|
||||
f"<b>{campaign_registration.campaign.name}</b>"
|
||||
)
|
||||
if campaign_registration.campaign.start_parameter:
|
||||
text += (
|
||||
" (параметр: "
|
||||
f"<code>{campaign_registration.campaign.start_parameter}</code>)"
|
||||
)
|
||||
text += "\n"
|
||||
text += (
|
||||
"• Всего регистраций: "
|
||||
f"{campaign_stats['registrations']}\n"
|
||||
)
|
||||
text += (
|
||||
"• Суммарный доход: "
|
||||
f"{settings.format_price(campaign_stats['total_revenue_kopeks'])}\n"
|
||||
)
|
||||
text += (
|
||||
"• Получили триал: "
|
||||
f"{campaign_stats['trial_users_count']}"
|
||||
f" (активно: {campaign_stats['active_trials_count']})\n"
|
||||
)
|
||||
text += (
|
||||
"• Конверсий в оплату: "
|
||||
f"{campaign_stats['conversion_count']}"
|
||||
f" (оплативших пользователей: {campaign_stats['paid_users_count']})\n"
|
||||
)
|
||||
text += (
|
||||
"• Конверсия в оплату: "
|
||||
f"{campaign_stats['conversion_rate']:.1f}%\n"
|
||||
)
|
||||
text += (
|
||||
"• Конверсия триала: "
|
||||
f"{campaign_stats['trial_conversion_rate']:.1f}%\n"
|
||||
)
|
||||
text += (
|
||||
"• Средний доход на пользователя: "
|
||||
f"{settings.format_price(campaign_stats['avg_revenue_per_user_kopeks'])}\n"
|
||||
)
|
||||
text += (
|
||||
"• Средний первый платеж: "
|
||||
f"{settings.format_price(campaign_stats['avg_first_payment_kopeks'])}\n"
|
||||
)
|
||||
text += "\n"
|
||||
|
||||
if referral_stats['invited_count'] > 0:
|
||||
text += f"<b>Доходы от рефералов:</b>\n"
|
||||
@@ -1455,68 +1855,9 @@ async def show_user_servers_management(
|
||||
db: AsyncSession
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
text = f"🌍 <b>Управление серверами пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
if subscription:
|
||||
current_squads = subscription.connected_squads or []
|
||||
|
||||
if current_squads:
|
||||
text += f"<b>Текущие серверы ({len(current_squads)}):</b>\n"
|
||||
|
||||
for squad_uuid in current_squads:
|
||||
try:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server:
|
||||
text += f"• {server.display_name}\n"
|
||||
else:
|
||||
text += f"• {squad_uuid[:8]}... (неизвестный)\n"
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сервера {squad_uuid}: {e}")
|
||||
text += f"• {squad_uuid[:8]}... (ошибка загрузки)\n"
|
||||
else:
|
||||
text += "<b>Серверы:</b> Не подключены\n"
|
||||
|
||||
text += f"\n<b>Устройства:</b> {subscription.device_limit}\n"
|
||||
traffic_display = f"{subscription.traffic_used_gb:.1f}/"
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
traffic_display += "∞ ГБ"
|
||||
else:
|
||||
traffic_display += f"{subscription.traffic_limit_gb} ГБ"
|
||||
text += f"<b>Трафик:</b> {traffic_display}\n"
|
||||
else:
|
||||
text += "❌ <b>Подписка отсутствует</b>"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Сменить сервер", callback_data=f"admin_user_change_server_{user_id}"),
|
||||
types.InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_user_devices_{user_id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_user_traffic_{user_id}"),
|
||||
types.InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data=f"admin_user_reset_devices_{user_id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
if await _render_user_subscription_overview(callback, db, user_id):
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -1552,7 +1893,7 @@ async def _show_servers_for_user(
|
||||
await callback.message.edit_text(
|
||||
"❌ Доступные серверы не найдены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")]
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
@@ -1595,8 +1936,8 @@ async def _show_servers_for_user(
|
||||
text += f"\n📝 Показано первых 20 из {len(servers_to_show)} серверов"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")
|
||||
types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -1687,7 +2028,7 @@ async def refresh_server_selection_screen(
|
||||
await callback.message.edit_text(
|
||||
"❌ Доступные серверы не найдены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")]
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
@@ -1711,8 +2052,8 @@ async def refresh_server_selection_screen(
|
||||
text += f"\n📝 Показано первых 15 из {len(servers)} серверов"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")
|
||||
types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -1752,7 +2093,7 @@ async def start_devices_edit(
|
||||
types.InlineKeyboardButton(text="10", callback_data=f"admin_user_devices_set_{user_id}_10")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}")
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
|
||||
]
|
||||
])
|
||||
)
|
||||
@@ -1778,14 +2119,14 @@ async def set_user_devices_button(
|
||||
await callback.message.edit_text(
|
||||
f"✅ Количество устройств изменено на: {devices}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка изменения количества устройств",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
@@ -1821,7 +2162,7 @@ async def process_devices_edit_text(
|
||||
await message.answer(
|
||||
f"✅ Количество устройств изменено на: {devices}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
@@ -1865,7 +2206,7 @@ async def start_traffic_edit(
|
||||
types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_user_traffic_set_{user_id}_0")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}")
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}")
|
||||
]
|
||||
])
|
||||
)
|
||||
@@ -1892,14 +2233,14 @@ async def set_user_traffic_button(
|
||||
await callback.message.edit_text(
|
||||
f"✅ Лимит трафика изменен на: {traffic_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка изменения лимита трафика",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
@@ -1936,7 +2277,7 @@ async def process_traffic_edit_text(
|
||||
await message.answer(
|
||||
f"✅ Лимит трафика изменен на: {traffic_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
@@ -1968,7 +2309,7 @@ async def confirm_reset_devices(
|
||||
"Продолжить?",
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
f"admin_user_reset_devices_confirm_{user_id}",
|
||||
f"admin_user_servers_{user_id}",
|
||||
f"admin_user_subscription_{user_id}",
|
||||
db_user.language
|
||||
)
|
||||
)
|
||||
@@ -1998,7 +2339,7 @@ async def reset_user_devices(
|
||||
await callback.message.edit_text(
|
||||
"✅ Устройства пользователя успешно сброшены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
logger.info(f"Админ {db_user.id} сбросил устройства пользователя {user_id}")
|
||||
@@ -2006,7 +2347,7 @@ async def reset_user_devices(
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка сброса устройств",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
@@ -2756,6 +3097,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("admin_users_list_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_balance_list_pagination,
|
||||
F.data.startswith("admin_users_balance_list_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_user_search,
|
||||
F.data == "admin_users_search"
|
||||
@@ -2770,7 +3116,17 @@ def register_handlers(dp: Dispatcher):
|
||||
show_user_management,
|
||||
F.data.startswith("admin_user_manage_")
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_promo_group,
|
||||
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
set_user_promo_group,
|
||||
F.data.startswith("admin_user_promo_group_set_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_balance_edit,
|
||||
F.data.startswith("admin_user_balance_")
|
||||
@@ -2938,6 +3294,22 @@ def register_handlers(dp: Dispatcher):
|
||||
admin_buy_subscription_execute,
|
||||
F.data.startswith("admin_buy_sub_execute_")
|
||||
)
|
||||
|
||||
# Регистрация обработчиков для фильтрации пользователей
|
||||
dp.callback_query.register(
|
||||
show_users_filters,
|
||||
F.data == "admin_users_filters"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_list_by_balance,
|
||||
F.data == "admin_users_balance_filter"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_list_by_balance,
|
||||
F.data.startswith("admin_users_balance_list_page_")
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
+78
-21
@@ -26,30 +26,22 @@ TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
def get_quick_amount_buttons(language: str) -> list:
|
||||
"""
|
||||
Генерирует кнопки быстрого выбора суммы пополнения на основе
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS и PRICE_*_DAYS
|
||||
"""
|
||||
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
return []
|
||||
|
||||
buttons = []
|
||||
periods = settings.get_available_subscription_periods()
|
||||
|
||||
# Ограничиваем до 6 кнопок (3 ряда по 2 кнопки)
|
||||
periods = periods[:6]
|
||||
|
||||
for period in periods:
|
||||
# Получаем цену из настроек
|
||||
price_attr = f"PRICE_{period}_DAYS"
|
||||
if hasattr(settings, price_attr):
|
||||
price_kopeks = getattr(settings, price_attr)
|
||||
price_rubles = price_kopeks // 100
|
||||
|
||||
# Создаем callback_data для каждой кнопки
|
||||
callback_data = f"quick_amount_{price_kopeks}"
|
||||
|
||||
# Добавляем кнопку
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{price_rubles} ₽ ({period} дней)",
|
||||
@@ -57,7 +49,6 @@ def get_quick_amount_buttons(language: str) -> list:
|
||||
)
|
||||
)
|
||||
|
||||
# Разбиваем кнопки на ряды (по 2 в ряд)
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i:i + 2])
|
||||
@@ -195,7 +186,7 @@ async def show_payment_methods(
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
payment_text = get_payment_methods_text()
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
@@ -213,8 +204,10 @@ async def handle_payment_methods_unavailable(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.answer(
|
||||
"⚠️ В данный момент автоматические способы оплаты временно недоступны. "
|
||||
"Для пополнения баланса обратитесь в техподдержку.",
|
||||
texts.t(
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT",
|
||||
"⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
@@ -386,7 +379,67 @@ async def start_tribute_payment(
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_successful_topup_with_cart(
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
bot,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.storage.base import StorageKey
|
||||
from app.bot import dp
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
storage = dp.storage
|
||||
key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id)
|
||||
|
||||
try:
|
||||
state_data = await storage.get_data(key)
|
||||
current_state = await storage.get_state(key)
|
||||
|
||||
if (current_state == "SubscriptionStates:cart_saved_for_topup" and
|
||||
state_data.get('saved_cart')):
|
||||
|
||||
texts = get_texts(user.language)
|
||||
total_price = state_data.get('total_price', 0)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="🛒 Вернуться к оформлению подписки",
|
||||
callback_data="return_to_saved_cart"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
success_text = (
|
||||
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
|
||||
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
|
||||
f"🛒 У вас есть сохраненная корзина подписки\n"
|
||||
f"Стоимость: {texts.format_price(total_price)}\n\n"
|
||||
f"Хотите продолжить оформление?"
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}")
|
||||
|
||||
@error_handler
|
||||
async def request_support_topup(
|
||||
@@ -399,7 +452,7 @@ async def request_support_topup(
|
||||
🛠️ <b>Пополнение через поддержку</b>
|
||||
|
||||
Для пополнения баланса обратитесь в техподдержку:
|
||||
{settings.SUPPORT_USERNAME}
|
||||
{settings.get_support_contact_display_html()}
|
||||
|
||||
Укажите:
|
||||
• ID: {db_user.telegram_id}
|
||||
@@ -416,8 +469,8 @@ async def request_support_topup(
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="💬 Написать в поддержку",
|
||||
url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}"
|
||||
text="💬 Написать в поддержку",
|
||||
url=settings.get_support_contact_url() or "https://t.me/"
|
||||
)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
@@ -606,7 +659,7 @@ async def process_yookassa_payment_amount(
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -691,7 +744,7 @@ async def process_yookassa_sbp_payment_amount(
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -752,7 +805,9 @@ async def check_yookassa_payment_status(
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_failed:
|
||||
message_text += f"\n❌ Платеж не прошел. Обратитесь в {settings.SUPPORT_USERNAME}"
|
||||
message_text += (
|
||||
f"\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
@@ -890,7 +945,7 @@ async def process_cryptobot_payment_amount(
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
|
||||
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -946,7 +1001,9 @@ async def check_cryptobot_payment_status(
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_expired:
|
||||
message_text += f"\n❌ Платеж истек. Обратитесь в {settings.SUPPORT_USERNAME}"
|
||||
message_text += (
|
||||
f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
|
||||
+46
-7
@@ -4,7 +4,7 @@ from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,13 +18,36 @@ async def handle_unknown_callback(
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
await callback.answer(
|
||||
"❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
show_alert=True
|
||||
texts.t(
|
||||
"UNKNOWN_CALLBACK_ALERT",
|
||||
"❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
async def handle_noop(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_current_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_cancel(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -49,8 +72,11 @@ async def handle_unknown_message(
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
await message.answer(
|
||||
"❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
reply_markup=get_back_keyboard(db_user.language if db_user else "ru")
|
||||
texts.t(
|
||||
"UNKNOWN_COMMAND_MESSAGE",
|
||||
"❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language if db_user else "ru"),
|
||||
)
|
||||
|
||||
|
||||
@@ -61,8 +87,8 @@ async def show_rules(
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
rules_text = texts.RULES_TEXT
|
||||
|
||||
rules_text = await get_rules(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
rules_text,
|
||||
@@ -77,9 +103,22 @@ def register_handlers(dp: Dispatcher):
|
||||
show_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
# No-op utility handlers used in many keyboards
|
||||
dp.callback_query.register(
|
||||
handle_noop,
|
||||
F.data == "noop"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_current_page,
|
||||
F.data == "current_page"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_cancel,
|
||||
F.data.in_(["cancel", "subscription_cancel"])
|
||||
)
|
||||
|
||||
# Самый последний: ловим любые неизвестные текстовые сообщения
|
||||
dp.message.register(handle_unknown_message)
|
||||
|
||||
+105
-63
@@ -7,36 +7,45 @@ from datetime import datetime, timedelta
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.database.models import User
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
|
||||
has_active_subscription = bool(db_user.subscription)
|
||||
subscription_is_active = False
|
||||
|
||||
|
||||
if db_user.subscription:
|
||||
subscription_is_active = db_user.subscription.is_active
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
@@ -44,11 +53,13 @@ async def show_main_menu(
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user: User
|
||||
@@ -61,34 +72,22 @@ async def mark_user_as_had_paid_subscription(
|
||||
|
||||
|
||||
async def show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
rules_text = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
|
||||
if not rules_text:
|
||||
texts = get_texts(db_user.language)
|
||||
rules_text = texts._get_default_rules(db_user.language) if hasattr(texts, '_get_default_rules') else """
|
||||
📋 <b>Правила использования сервиса</b>
|
||||
rules_text = await get_rules(db_user.language)
|
||||
|
||||
1. Запрещается использование сервиса для незаконной деятельности
|
||||
2. Запрещается нарушение авторских прав
|
||||
3. Запрещается спам и рассылка вредоносного ПО
|
||||
4. Запрещается использование сервиса для DDoS атак
|
||||
5. Один аккаунт - один пользователь
|
||||
6. Возврат средств производится только в исключительных случаях
|
||||
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
|
||||
|
||||
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Правила сервиса</b>\n\n{rules_text}",
|
||||
f"{texts.t('RULES_HEADER', '📋 <b>Правила сервиса</b>')}\n\n{rules_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -101,82 +100,125 @@ async def handle_back_to_menu(
|
||||
db: AsyncSession
|
||||
):
|
||||
await state.clear()
|
||||
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
has_active_subscription = db_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
|
||||
if db_user.subscription:
|
||||
subscription_is_active = db_user.subscription.is_active
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
return "❌ Отсутствует"
|
||||
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
|
||||
|
||||
subscription = user.subscription
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if subscription.end_date <= current_time:
|
||||
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
|
||||
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
|
||||
if subscription.is_trial:
|
||||
if days_left > 1:
|
||||
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(
|
||||
end_date=subscription.end_date.strftime('%d.%m.%Y'),
|
||||
days=days_left,
|
||||
)
|
||||
elif days_left == 1:
|
||||
return f"🎁 Тестовая подписка\n⚠️ истекает завтра!"
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
|
||||
)
|
||||
else:
|
||||
return f"🎁 Тестовая подписка\n⚠️ истекает сегодня!"
|
||||
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TODAY",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
else:
|
||||
if days_left > 7:
|
||||
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(
|
||||
end_date=subscription.end_date.strftime('%d.%m.%Y'),
|
||||
days=days_left,
|
||||
)
|
||||
elif days_left > 1:
|
||||
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
"💎 Активна\n⚠️ истекает через {days} дн.",
|
||||
).format(days=days_left)
|
||||
elif days_left == 1:
|
||||
return f"💎 Активна\n⚠️ истекает завтра!"
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TOMORROW",
|
||||
"💎 Активна\n⚠️ истекает завтра!",
|
||||
)
|
||||
else:
|
||||
return f"💎 Активна\n⚠️ истекает сегодня!"
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TODAY",
|
||||
"💎 Активна\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
|
||||
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
|
||||
if not random_message:
|
||||
return base_text
|
||||
|
||||
prompt = action_prompt or ""
|
||||
if prompt and prompt in base_text:
|
||||
parts = base_text.split(prompt, 1)
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}"
|
||||
return base_text.replace(prompt, f"\n{random_message}\n\n{prompt}", 1)
|
||||
|
||||
return f"{base_text}\n\n{random_message}"
|
||||
|
||||
|
||||
async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
if "Выберите действие:" in base_text:
|
||||
parts = base_text.split("Выберите действие:")
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
|
||||
|
||||
if "Выберите действие:" in base_text:
|
||||
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
|
||||
else:
|
||||
return f"{base_text}\n\n{random_message}"
|
||||
return _insert_random_message(base_text, random_message, action_prompt)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
@@ -44,7 +44,10 @@ async def process_promocode(
|
||||
|
||||
if not code:
|
||||
await message.answer(
|
||||
"❌ Введите корректный промокод",
|
||||
texts.t(
|
||||
"PROMOCODE_EMPTY_INPUT",
|
||||
"❌ Введите корректный промокод",
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
+238
-72
@@ -33,64 +33,149 @@ async def show_referral_info(
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
referral_text = f"👥 <b>Реферальная программа</b>\n\n"
|
||||
|
||||
referral_text += f"📊 <b>Ваша статистика:</b>\n"
|
||||
referral_text += f"• Приглашено пользователей: <b>{summary['invited_count']}</b>\n"
|
||||
referral_text += f"• Сделали первое пополнение: <b>{summary['paid_referrals_count']}</b>\n"
|
||||
referral_text += f"• Активных рефералов: <b>{summary['active_referrals_count']}</b>\n"
|
||||
referral_text += f"• Конверсия: <b>{summary['conversion_rate']}%</b>\n"
|
||||
referral_text += f"• Заработано всего: <b>{texts.format_price(summary['total_earned_kopeks'])}</b>\n"
|
||||
referral_text += f"• За последний месяц: <b>{texts.format_price(summary['month_earned_kopeks'])}</b>\n\n"
|
||||
|
||||
referral_text += f"🎁 <b>Как работают награды:</b>\n"
|
||||
referral_text += f"• Новый пользователь получает: <b>{texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}</b> при первом пополнении от <b>{texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}</b>\n"
|
||||
referral_text += f"• Вы получаете при первом пополнении реферала: <b>{texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}</b>\n"
|
||||
referral_text += f"• Комиссия с каждого пополнения реферала: <b>{settings.REFERRAL_COMMISSION_PERCENT}%</b>\n\n"
|
||||
|
||||
referral_text += f"🔗 <b>Ваша реферальная ссылка:</b>\n"
|
||||
referral_text += f"<code>{referral_link}</code>\n\n"
|
||||
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
|
||||
|
||||
referral_text = (
|
||||
texts.t("REFERRAL_PROGRAM_TITLE", "👥 <b>Реферальная программа</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_STATS_HEADER", "📊 <b>Ваша статистика:</b>")
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_INVITED",
|
||||
"• Приглашено пользователей: <b>{count}</b>",
|
||||
).format(count=summary['invited_count'])
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_FIRST_TOPUPS",
|
||||
"• Сделали первое пополнение: <b>{count}</b>",
|
||||
).format(count=summary['paid_referrals_count'])
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_ACTIVE",
|
||||
"• Активных рефералов: <b>{count}</b>",
|
||||
).format(count=summary['active_referrals_count'])
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_CONVERSION",
|
||||
"• Конверсия: <b>{rate}%</b>",
|
||||
).format(rate=summary['conversion_rate'])
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_TOTAL_EARNED",
|
||||
"• Заработано всего: <b>{amount}</b>",
|
||||
).format(amount=texts.format_price(summary['total_earned_kopeks']))
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_MONTH_EARNED",
|
||||
"• За последний месяц: <b>{amount}</b>",
|
||||
).format(amount=texts.format_price(summary['month_earned_kopeks']))
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_REWARDS_HEADER", "🎁 <b>Как работают награды:</b>")
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_REWARD_NEW_USER",
|
||||
"• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
|
||||
).format(
|
||||
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
|
||||
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
|
||||
)
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_REWARD_INVITER",
|
||||
"• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
|
||||
).format(bonus=texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS))
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_REWARD_COMMISSION",
|
||||
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
||||
).format(percent=settings.REFERRAL_COMMISSION_PERCENT)
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
|
||||
+ f"\n<code>{referral_link}</code>\n\n"
|
||||
+ texts.t("REFERRAL_CODE_TITLE", "🆔 <b>Ваш код:</b> <code>{code}</code>").format(code=db_user.referral_code)
|
||||
+ "\n\n"
|
||||
)
|
||||
|
||||
if summary['recent_earnings']:
|
||||
meaningful_earnings = [
|
||||
earning for earning in summary['recent_earnings'][:5]
|
||||
earning for earning in summary['recent_earnings'][:5]
|
||||
if earning['amount_kopeks'] > 0
|
||||
]
|
||||
|
||||
|
||||
if meaningful_earnings:
|
||||
referral_text += f"💰 <b>Последние начисления:</b>\n"
|
||||
for earning in meaningful_earnings[:3]:
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_RECENT_EARNINGS_HEADER",
|
||||
"💰 <b>Последние начисления:</b>",
|
||||
) + "\n"
|
||||
for earning in meaningful_earnings[:3]:
|
||||
reason_text = {
|
||||
"referral_first_topup": "🎉 Первое пополнение",
|
||||
"referral_commission_topup": "💰 Комиссия с пополнения",
|
||||
"referral_commission": "💰 Комиссия с покупки"
|
||||
"referral_first_topup": texts.t(
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP",
|
||||
"🎉 Первое пополнение",
|
||||
),
|
||||
"referral_commission_topup": texts.t(
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP",
|
||||
"💰 Комиссия с пополнения",
|
||||
),
|
||||
"referral_commission": texts.t(
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE",
|
||||
"💰 Комиссия с покупки",
|
||||
),
|
||||
}.get(earning['reason'], earning['reason'])
|
||||
|
||||
referral_text += f"• {reason_text}: <b>{texts.format_price(earning['amount_kopeks'])}</b> от {earning['referral_name']}\n"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM",
|
||||
"• {reason}: <b>{amount}</b> от {referral_name}",
|
||||
).format(
|
||||
reason=reason_text,
|
||||
amount=texts.format_price(earning['amount_kopeks']),
|
||||
referral_name=earning['referral_name'],
|
||||
) + "\n"
|
||||
referral_text += "\n"
|
||||
|
||||
|
||||
if summary['earnings_by_type']:
|
||||
referral_text += f"📈 <b>Доходы по типам:</b>\n"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER",
|
||||
"📈 <b>Доходы по типам:</b>",
|
||||
) + "\n"
|
||||
|
||||
if 'referral_first_topup' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_first_topup']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Бонусы за первые пополнения: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_EARNINGS_FIRST_TOPUPS",
|
||||
"• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
|
||||
).format(
|
||||
count=data['count'],
|
||||
amount=texts.format_price(data['total_amount_kopeks']),
|
||||
) + "\n"
|
||||
|
||||
if 'referral_commission_topup' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_commission_topup']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Комиссии с пополнений: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_EARNINGS_TOPUPS",
|
||||
"• Комиссии с пополнений: <b>{count}</b> ({amount})",
|
||||
).format(
|
||||
count=data['count'],
|
||||
amount=texts.format_price(data['total_amount_kopeks']),
|
||||
) + "\n"
|
||||
|
||||
if 'referral_commission' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_commission']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Комиссии с покупок: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_EARNINGS_PURCHASES",
|
||||
"• Комиссии с покупок: <b>{count}</b> ({amount})",
|
||||
).format(
|
||||
count=data['count'],
|
||||
amount=texts.format_price(data['total_amount_kopeks']),
|
||||
) + "\n"
|
||||
|
||||
referral_text += "\n"
|
||||
|
||||
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_INVITE_FOOTER",
|
||||
"📢 Приглашайте друзей и зарабатывайте!",
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
@@ -128,7 +213,10 @@ async def show_referral_qr(
|
||||
await callback.message.edit_media(
|
||||
types.InputMediaPhoto(
|
||||
media=photo,
|
||||
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
|
||||
caption=texts.t(
|
||||
"REFERRAL_LINK_CAPTION",
|
||||
"🔗 Ваша реферальная ссылка:\n{link}",
|
||||
).format(link=referral_link),
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -136,7 +224,10 @@ async def show_referral_qr(
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
photo,
|
||||
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
|
||||
caption=texts.t(
|
||||
"REFERRAL_LINK_CAPTION",
|
||||
"🔗 Ваша реферальная ссылка:\n{link}",
|
||||
).format(link=referral_link),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
@@ -154,7 +245,10 @@ async def show_detailed_referral_list(
|
||||
if not referrals_data['referrals']:
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
texts.t(
|
||||
"REFERRAL_LIST_EMPTY",
|
||||
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
),
|
||||
types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
|
||||
),
|
||||
@@ -162,23 +256,47 @@ async def show_detailed_referral_list(
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"👥 <b>Ваши рефералы</b> (стр. {referrals_data['current_page']}/{referrals_data['total_pages']})\n\n"
|
||||
|
||||
text = texts.t(
|
||||
"REFERRAL_LIST_HEADER",
|
||||
"👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
|
||||
).format(
|
||||
current=referrals_data['current_page'],
|
||||
total=referrals_data['total_pages'],
|
||||
) + "\n\n"
|
||||
|
||||
for i, referral in enumerate(referrals_data['referrals'], 1):
|
||||
status_emoji = "🟢" if referral['status'] == 'active' else "🔴"
|
||||
|
||||
topup_emoji = "💰" if referral['has_made_first_topup'] else "⏳"
|
||||
|
||||
text += f"{i}. {status_emoji} <b>{referral['full_name']}</b>\n"
|
||||
text += f" {topup_emoji} Пополнений: {referral['topups_count']}\n"
|
||||
text += f" 💎 Заработано с него: {texts.format_price(referral['total_earned_kopeks'])}\n"
|
||||
text += f" 📅 Регистрация: {referral['days_since_registration']} дн. назад\n"
|
||||
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_HEADER",
|
||||
"{index}. {status} <b>{name}</b>",
|
||||
).format(index=i, status=status_emoji, name=referral['full_name']) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_TOPUPS",
|
||||
" {emoji} Пополнений: {count}",
|
||||
).format(emoji=topup_emoji, count=referral['topups_count']) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_EARNED",
|
||||
" 💎 Заработано с него: {amount}",
|
||||
).format(amount=texts.format_price(referral['total_earned_kopeks'])) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_REGISTERED",
|
||||
" 📅 Регистрация: {days} дн. назад",
|
||||
).format(days=referral['days_since_registration']) + "\n"
|
||||
|
||||
if referral['days_since_activity'] is not None:
|
||||
text += f" 🕐 Активность: {referral['days_since_activity']} дн. назад\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY",
|
||||
" 🕐 Активность: {days} дн. назад",
|
||||
).format(days=referral['days_since_activity']) + "\n"
|
||||
else:
|
||||
text += f" 🕐 Активность: давно\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO",
|
||||
" 🕐 Активность: давно",
|
||||
) + "\n"
|
||||
|
||||
text += "\n"
|
||||
|
||||
@@ -187,13 +305,13 @@ async def show_detailed_referral_list(
|
||||
|
||||
if referrals_data['has_prev']:
|
||||
nav_buttons.append(types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
text=texts.t("REFERRAL_LIST_PREV_PAGE", "⬅️ Назад"),
|
||||
callback_data=f"referral_list_page_{page - 1}"
|
||||
))
|
||||
|
||||
|
||||
if referrals_data['has_next']:
|
||||
nav_buttons.append(types.InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
text=texts.t("REFERRAL_LIST_NEXT_PAGE", "Вперед ➡️"),
|
||||
callback_data=f"referral_list_page_{page + 1}"
|
||||
))
|
||||
|
||||
@@ -222,21 +340,50 @@ async def show_referral_analytics(
|
||||
|
||||
analytics = await get_referral_analytics(db, db_user.id)
|
||||
|
||||
text = f"📊 <b>Аналитика рефералов</b>\n\n"
|
||||
text = texts.t("REFERRAL_ANALYTICS_TITLE", "📊 <b>Аналитика рефералов</b>") + "\n\n"
|
||||
|
||||
text += f"💰 <b>Доходы по периодам:</b>\n"
|
||||
text += f"• Сегодня: {texts.format_price(analytics['earnings_by_period']['today'])}\n"
|
||||
text += f"• За неделю: {texts.format_price(analytics['earnings_by_period']['week'])}\n"
|
||||
text += f"• За месяц: {texts.format_price(analytics['earnings_by_period']['month'])}\n"
|
||||
text += f"• За квартал: {texts.format_price(analytics['earnings_by_period']['quarter'])}\n\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_EARNINGS_HEADER",
|
||||
"💰 <b>Доходы по периодам:</b>",
|
||||
) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_EARNINGS_TODAY",
|
||||
"• Сегодня: {amount}",
|
||||
).format(amount=texts.format_price(analytics['earnings_by_period']['today'])) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_EARNINGS_WEEK",
|
||||
"• За неделю: {amount}",
|
||||
).format(amount=texts.format_price(analytics['earnings_by_period']['week'])) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_EARNINGS_MONTH",
|
||||
"• За месяц: {amount}",
|
||||
).format(amount=texts.format_price(analytics['earnings_by_period']['month'])) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_EARNINGS_QUARTER",
|
||||
"• За квартал: {amount}",
|
||||
).format(amount=texts.format_price(analytics['earnings_by_period']['quarter'])) + "\n\n"
|
||||
|
||||
if analytics['top_referrals']:
|
||||
text += f"🏆 <b>Топ-{len(analytics['top_referrals'])} рефералов:</b>\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_TOP_TITLE",
|
||||
"🏆 <b>Топ-{count} рефералов:</b>",
|
||||
).format(count=len(analytics['top_referrals'])) + "\n"
|
||||
for i, ref in enumerate(analytics['top_referrals'], 1):
|
||||
text += f"{i}. {ref['referral_name']}: {texts.format_price(ref['total_earned_kopeks'])} ({ref['earnings_count']} начислений)\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_TOP_ITEM",
|
||||
"{index}. {name}: {amount} ({count} начислений)",
|
||||
).format(
|
||||
index=i,
|
||||
name=ref['referral_name'],
|
||||
amount=texts.format_price(ref['total_earned_kopeks']),
|
||||
count=ref['earnings_count'],
|
||||
) + "\n"
|
||||
text += "\n"
|
||||
|
||||
text += "📈 Продолжайте развивать свою реферальную сеть!"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_FOOTER",
|
||||
"📈 Продолжайте развивать свою реферальную сеть!",
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
@@ -257,16 +404,30 @@ async def create_invite_message(
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
invite_text = f"🎉 Присоединяйся к VPN сервису!\n\n"
|
||||
invite_text += f"💎 При первом пополнении от {texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} ты получишь {texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)} бонусом на баланс!\n\n"
|
||||
invite_text += f"🚀 Быстрое подключение\n"
|
||||
invite_text += f"🌍 Серверы по всему миру\n"
|
||||
invite_text += f"🔒 Надежная защита\n\n"
|
||||
invite_text += f"👇 Переходи по ссылке:\n{referral_link}"
|
||||
invite_text = (
|
||||
texts.t("REFERRAL_INVITE_TITLE", "🎉 Присоединяйся к VPN сервису!")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_INVITE_BONUS",
|
||||
"💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
|
||||
).format(
|
||||
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
|
||||
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_INVITE_FEATURE_FAST", "🚀 Быстрое подключение")
|
||||
+ "\n"
|
||||
+ texts.t("REFERRAL_INVITE_FEATURE_SERVERS", "🌍 Серверы по всему миру")
|
||||
+ "\n"
|
||||
+ texts.t("REFERRAL_INVITE_FEATURE_SECURE", "🔒 Надежная защита")
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_INVITE_LINK_PROMPT", "👇 Переходи по ссылке:")
|
||||
+ f"\n{referral_link}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="📤 Поделиться",
|
||||
text=texts.t("REFERRAL_SHARE_BUTTON", "📤 Поделиться"),
|
||||
switch_inline_query=invite_text
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
@@ -278,8 +439,13 @@ async def create_invite_message(
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
(
|
||||
f"📝 <b>Приглашение создано!</b>\n\n"
|
||||
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
|
||||
texts.t("REFERRAL_INVITE_CREATED_TITLE", "📝 <b>Приглашение создано!</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION",
|
||||
"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
|
||||
)
|
||||
+ "\n\n"
|
||||
f"<code>{invite_text}</code>"
|
||||
),
|
||||
keyboard,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_server_status_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.server_status_service import (
|
||||
ServerStatusEntry,
|
||||
ServerStatusError,
|
||||
ServerStatusService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_status_service = ServerStatusService()
|
||||
|
||||
|
||||
async def show_server_status(callback: types.CallbackQuery, db_user: User) -> None:
|
||||
await _render_server_status(callback, db_user, page=1)
|
||||
|
||||
|
||||
async def change_server_status_page(callback: types.CallbackQuery, db_user: User) -> None:
|
||||
try:
|
||||
_, page_str = callback.data.split(":", 1)
|
||||
page = int(page_str)
|
||||
except (ValueError, AttributeError, IndexError):
|
||||
page = 1
|
||||
|
||||
await _render_server_status(callback, db_user, page=page)
|
||||
|
||||
|
||||
async def _render_server_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
page: int = 1,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if settings.get_server_status_mode() != "xray":
|
||||
await callback.answer(texts.t("SERVER_STATUS_NOT_CONFIGURED", "Функция недоступна."), show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
servers = await _status_service.get_servers()
|
||||
except ServerStatusError as error:
|
||||
logger.warning("Server status error: %s", error)
|
||||
await callback.answer(
|
||||
texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error("Unexpected server status error: %s", error)
|
||||
await callback.answer(
|
||||
texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
message, total_pages, current_page = _build_status_message(servers, texts, page)
|
||||
keyboard = get_server_status_keyboard(db_user.language, current_page, total_pages)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _build_status_message(
|
||||
servers: List[ServerStatusEntry],
|
||||
texts,
|
||||
page: int,
|
||||
) -> Tuple[str, int, int]:
|
||||
total_servers = len(servers)
|
||||
online_servers = [server for server in servers if server.is_online]
|
||||
offline_servers = [server for server in servers if not server.is_online]
|
||||
|
||||
items_per_page = settings.get_server_status_items_per_page()
|
||||
pages = _split_into_pages(online_servers, offline_servers, items_per_page)
|
||||
|
||||
total_pages = max(1, len(pages))
|
||||
current_index = min(max(page - 1, 0), total_pages - 1)
|
||||
|
||||
current_online, current_offline = pages[current_index] if pages else ([], [])
|
||||
|
||||
lines: List[str] = [texts.t("SERVER_STATUS_TITLE", "📊 <b>Статус серверов</b>")]
|
||||
|
||||
if total_servers == 0:
|
||||
lines.append("")
|
||||
lines.append(texts.t("SERVER_STATUS_NO_SERVERS", "Нет данных о серверах."))
|
||||
message = "\n".join(lines).strip()
|
||||
return message, 1, 1
|
||||
|
||||
summary = texts.t(
|
||||
"SERVER_STATUS_SUMMARY",
|
||||
"Всего серверов: {total} (в сети: {online}, вне сети: {offline})",
|
||||
).format(
|
||||
total=total_servers,
|
||||
online=len(online_servers),
|
||||
offline=len(offline_servers),
|
||||
)
|
||||
|
||||
updated_at = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
summary,
|
||||
texts.t("SERVER_STATUS_UPDATED_AT", "⏱ Обновлено: {time}").format(time=updated_at),
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
if current_online:
|
||||
lines.append(texts.t("SERVER_STATUS_AVAILABLE", "✅ <b>Доступны</b>"))
|
||||
lines.extend(_format_server_lines(current_online, texts, online=True))
|
||||
lines.append("")
|
||||
|
||||
if current_offline:
|
||||
lines.append(texts.t("SERVER_STATUS_UNAVAILABLE", "❌ <b>Недоступны</b>"))
|
||||
lines.extend(_format_server_lines(current_offline, texts, online=False))
|
||||
lines.append("")
|
||||
|
||||
if total_pages > 1:
|
||||
lines.append(
|
||||
texts.t("SERVER_STATUS_PAGINATION", "Страница {current} из {total}").format(
|
||||
current=current_index + 1,
|
||||
total=total_pages,
|
||||
)
|
||||
)
|
||||
|
||||
message = "\n".join(line for line in lines if line is not None)
|
||||
message = message.strip()
|
||||
return message, total_pages, current_index + 1
|
||||
|
||||
|
||||
def _split_into_pages(
|
||||
online: List[ServerStatusEntry],
|
||||
offline: List[ServerStatusEntry],
|
||||
items_per_page: int,
|
||||
) -> List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]]:
|
||||
if not online and not offline:
|
||||
return [([], [])]
|
||||
|
||||
pages: List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]] = []
|
||||
online_index = 0
|
||||
offline_index = 0
|
||||
|
||||
while online_index < len(online) or offline_index < len(offline):
|
||||
current_online: List[ServerStatusEntry] = []
|
||||
current_offline: List[ServerStatusEntry] = []
|
||||
remaining = max(1, items_per_page)
|
||||
|
||||
while remaining > 0 and online_index < len(online):
|
||||
current_online.append(online[online_index])
|
||||
online_index += 1
|
||||
remaining -= 1
|
||||
|
||||
while remaining > 0 and offline_index < len(offline):
|
||||
current_offline.append(offline[offline_index])
|
||||
offline_index += 1
|
||||
remaining -= 1
|
||||
|
||||
pages.append((current_online, current_offline))
|
||||
|
||||
return pages if pages else [([], [])]
|
||||
|
||||
|
||||
def _format_server_lines(
|
||||
servers: List[ServerStatusEntry],
|
||||
texts,
|
||||
*,
|
||||
online: bool,
|
||||
) -> List[str]:
|
||||
lines: List[str] = []
|
||||
for server in servers:
|
||||
latency_text: str
|
||||
if online:
|
||||
if server.latency_ms and server.latency_ms > 0:
|
||||
latency_text = texts.t("SERVER_STATUS_LATENCY", "{latency} мс").format(
|
||||
latency=server.latency_ms
|
||||
)
|
||||
else:
|
||||
latency_text = texts.t("SERVER_STATUS_LATENCY_UNKNOWN", "нет данных")
|
||||
else:
|
||||
latency_text = texts.t("SERVER_STATUS_OFFLINE", "нет ответа")
|
||||
|
||||
name = server.display_name or server.name
|
||||
flag_prefix = f"{server.flag} " if server.flag else ""
|
||||
server_line = f"{flag_prefix}{name} — {latency_text}"
|
||||
lines.append(f"<blockquote>{server_line}</blockquote>")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_server_status,
|
||||
F.data == "menu_server_status",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
change_server_status_page,
|
||||
F.data.startswith("server_status_page:"),
|
||||
)
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Ошибка валидации платежа. Попробуйте еще раз."
|
||||
error_message=texts.t(
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD",
|
||||
"Ошибка валидации платежа. Попробуйте еще раз.",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
async for db in get_db():
|
||||
@@ -32,26 +37,36 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Пользователь не найден. Обратитесь в поддержку."
|
||||
error_message=texts.t(
|
||||
"STARS_PRECHECK_USER_NOT_FOUND",
|
||||
"Пользователь не найден. Обратитесь в поддержку.",
|
||||
),
|
||||
)
|
||||
return
|
||||
break
|
||||
texts = get_texts(user.language or DEFAULT_LANGUAGE)
|
||||
break
|
||||
except Exception as db_error:
|
||||
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
error_message=texts.t(
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR",
|
||||
"Техническая ошибка. Попробуйте позже.",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
await query.answer(ok=True)
|
||||
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
error_message=texts.t(
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR",
|
||||
"Техническая ошибка. Попробуйте позже.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -60,25 +75,32 @@ async def handle_successful_payment(
|
||||
db: AsyncSession,
|
||||
**kwargs
|
||||
):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
|
||||
try:
|
||||
payment = message.successful_payment
|
||||
user_id = message.from_user.id
|
||||
|
||||
|
||||
logger.info(
|
||||
f"💳 Успешный Stars платеж от {user_id}: "
|
||||
f"{payment.total_amount} XTR, "
|
||||
f"payload: {payment.invoice_payload}, "
|
||||
f"charge_id: {payment.telegram_payment_charge_id}"
|
||||
)
|
||||
|
||||
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
texts = get_texts(user.language if user and user.language else DEFAULT_LANGUAGE)
|
||||
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
|
||||
await message.answer(
|
||||
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку."
|
||||
texts.t(
|
||||
"STARS_PAYMENT_USER_NOT_FOUND",
|
||||
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
success = await payment_service.process_stars_payment(
|
||||
db=db,
|
||||
@@ -91,40 +113,23 @@ async def handle_successful_payment(
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
keyboard = await payment_service.build_topup_success_keyboard(user)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
transaction_id_short = payment.telegram_payment_charge_id[:8]
|
||||
|
||||
await message.answer(
|
||||
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
f"⭐ Потрачено звезд: {payment.total_amount}\n"
|
||||
f"💰 Зачислено на баланс: {int(rubles_amount)} ₽\n"
|
||||
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Спасибо за пополнение! 🚀",
|
||||
texts.t(
|
||||
"STARS_PAYMENT_SUCCESS",
|
||||
"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
"⭐ Потрачено звезд: {stars_spent}\n"
|
||||
"💰 Зачислено на баланс: {amount} ₽\n"
|
||||
"🆔 ID транзакции: {transaction_id}...\n\n"
|
||||
"Спасибо за пополнение! 🚀",
|
||||
).format(
|
||||
stars_spent=payment.total_amount,
|
||||
amount=int(rubles_amount),
|
||||
transaction_id=transaction_id_short,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -136,15 +141,21 @@ async def handle_successful_payment(
|
||||
else:
|
||||
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при зачислении средств. "
|
||||
"Обратитесь в поддержку, платеж будет проверен вручную."
|
||||
texts.t(
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR",
|
||||
"❌ Произошла ошибка при зачислении средств. "
|
||||
"Обратитесь в поддержку, платеж будет проверен вручную.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
|
||||
await message.answer(
|
||||
"❌ Техническая ошибка при обработке платежа. "
|
||||
"Обратитесь в поддержку для решения проблемы."
|
||||
texts.t(
|
||||
"STARS_PAYMENT_PROCESSING_ERROR",
|
||||
"❌ Техническая ошибка при обработке платежа. "
|
||||
"Обратитесь в поддержку для решения проблемы.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
+422
-202
@@ -9,23 +9,67 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.states import RegistrationStates
|
||||
from app.database.crud.user import (
|
||||
get_user_by_telegram_id, create_user, get_user_by_referral_code
|
||||
get_user_by_telegram_id,
|
||||
create_user,
|
||||
get_user_by_referral_code,
|
||||
)
|
||||
from app.database.crud.campaign import (
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_by_id,
|
||||
)
|
||||
from app.database.models import UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _apply_campaign_bonus_if_needed(
|
||||
db: AsyncSession,
|
||||
user,
|
||||
state_data: dict,
|
||||
texts,
|
||||
):
|
||||
campaign_id = state_data.get("campaign_id") if state_data else None
|
||||
if not campaign_id:
|
||||
return None
|
||||
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign or not campaign.is_active:
|
||||
return None
|
||||
|
||||
service = AdvertisingCampaignService()
|
||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||
if not result.success:
|
||||
return None
|
||||
|
||||
if result.bonus_type == "balance":
|
||||
amount_text = texts.format_price(result.balance_kopeks)
|
||||
return texts.CAMPAIGN_BONUS_BALANCE.format(
|
||||
amount=amount_text,
|
||||
name=campaign.name,
|
||||
)
|
||||
|
||||
if result.bonus_type == "subscription":
|
||||
traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0)
|
||||
return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format(
|
||||
name=campaign.name,
|
||||
days=result.subscription_days,
|
||||
traffic=traffic_text,
|
||||
devices=result.subscription_device_limit,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def handle_potential_referral_code(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
@@ -44,34 +88,43 @@ async def handle_potential_referral_code(
|
||||
user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
if user and user.status == UserStatus.ACTIVE.value:
|
||||
return False
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = (
|
||||
data.get("language")
|
||||
or (getattr(user, "language", None) if user else None)
|
||||
or DEFAULT_LANGUAGE
|
||||
)
|
||||
texts = get_texts(language)
|
||||
|
||||
potential_code = message.text.strip()
|
||||
if len(potential_code) < 4 or len(potential_code) > 20:
|
||||
return False
|
||||
|
||||
|
||||
referrer = await get_user_by_referral_code(db, potential_code)
|
||||
if not referrer:
|
||||
await message.answer(
|
||||
await message.answer(texts.t(
|
||||
"REFERRAL_CODE_INVALID_HELP",
|
||||
"❌ Неверный реферальный код.\n\n"
|
||||
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
|
||||
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start"
|
||||
)
|
||||
return True
|
||||
|
||||
data = await state.get_data() or {}
|
||||
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
|
||||
))
|
||||
return True
|
||||
|
||||
data['referral_code'] = potential_code
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer("✅ Реферальный код принят!")
|
||||
|
||||
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
|
||||
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
|
||||
|
||||
if current_state != RegistrationStates.waiting_for_referral_code.state:
|
||||
language = data.get('language', 'ru')
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
texts.RULES_TEXT,
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
@@ -86,13 +139,29 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
|
||||
|
||||
referral_code = None
|
||||
if len(message.text.split()) > 1:
|
||||
potential_code = message.text.split()[1]
|
||||
referral_code = potential_code
|
||||
logger.info(f"🔎 Найден реферальный код: {referral_code}")
|
||||
|
||||
campaign = None
|
||||
start_args = message.text.split()
|
||||
if len(start_args) > 1:
|
||||
start_parameter = start_args[1]
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
start_parameter,
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
if campaign:
|
||||
logger.info(
|
||||
"📣 Найдена рекламная кампания %s (start=%s)",
|
||||
campaign.id,
|
||||
campaign.start_parameter,
|
||||
)
|
||||
await state.update_data(campaign_id=campaign.id)
|
||||
else:
|
||||
referral_code = start_parameter
|
||||
logger.info(f"🔎 Найден реферальный код: {referral_code}")
|
||||
|
||||
if referral_code:
|
||||
await state.set_data({'referral_code': referral_code})
|
||||
await state.update_data(referral_code=referral_code)
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
@@ -130,9 +199,27 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
await db.commit()
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
|
||||
if referral_code and not user.referred_by_id:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ALREADY_REGISTERED_REFERRAL",
|
||||
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
)
|
||||
)
|
||||
|
||||
if campaign:
|
||||
try:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"CAMPAIGN_EXISTING_USERL",
|
||||
"ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления о рекламной кампании: {e}"
|
||||
)
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
@@ -221,13 +308,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
else:
|
||||
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
|
||||
|
||||
language = 'ru'
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
logger.info(f"💾 Установлен русский язык по умолчанию")
|
||||
logger.info(f"💾 Установлен язык по умолчанию: {language}")
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
|
||||
if data.get('referral_code'):
|
||||
@@ -242,7 +329,10 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
else:
|
||||
try:
|
||||
await message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
@@ -252,8 +342,9 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
await complete_registration(message, state, db)
|
||||
return
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
await message.answer(
|
||||
texts.RULES_TEXT,
|
||||
rules_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"📋 Правила отправлены")
|
||||
@@ -276,11 +367,14 @@ async def process_rules_accept(
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Текущее состояние: {current_state}")
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
try:
|
||||
await callback.answer()
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', language)
|
||||
texts = get_texts(language)
|
||||
|
||||
if callback.data == 'rules_accept':
|
||||
@@ -293,10 +387,13 @@ async def process_rules_accept(
|
||||
logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"✅ Правила приняты! Завершаем регистрацию...",
|
||||
texts.t(
|
||||
"RULES_ACCEPTED_PROCESSING",
|
||||
"✅ Правила приняты! Завершаем регистрацию...",
|
||||
),
|
||||
reply_markup=None
|
||||
)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if data.get('referral_code'):
|
||||
@@ -316,7 +413,10 @@ async def process_rules_accept(
|
||||
else:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
@@ -328,9 +428,12 @@ async def process_rules_accept(
|
||||
else:
|
||||
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
||||
|
||||
rules_required_text = texts.t(
|
||||
"RULES_REQUIRED",
|
||||
"Для использования бота необходимо принять правила сервиса.",
|
||||
)
|
||||
|
||||
try:
|
||||
rules_required_text = getattr(texts, 'RULES_REQUIRED',
|
||||
"Для использования бота необходимо принять правила сервиса.")
|
||||
await callback.message.edit_text(
|
||||
rules_required_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
@@ -338,7 +441,7 @@ async def process_rules_accept(
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
|
||||
await callback.message.edit_text(
|
||||
"Для использования бота необходимо принять правила сервиса.",
|
||||
rules_required_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
|
||||
@@ -346,13 +449,20 @@ async def process_rules_accept(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True)
|
||||
await callback.answer("❌ Произошла ошибка. Попробуйте еще раз.", show_alert=True)
|
||||
|
||||
await callback.answer(
|
||||
texts.t("ERROR_TRY_AGAIN", "❌ Произошла ошибка. Попробуйте еще раз."),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', language)
|
||||
texts = get_texts(language)
|
||||
await callback.message.answer(
|
||||
"Произошла ошибка. Попробуйте принять правила еще раз:",
|
||||
texts.t(
|
||||
"ERROR_RULES_RETRY",
|
||||
"Произошла ошибка. Попробуйте принять правила еще раз:",
|
||||
),
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
@@ -368,20 +478,20 @@ async def process_referral_code_input(
|
||||
|
||||
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
referral_code = message.text.strip()
|
||||
|
||||
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
await message.answer("✅ Реферальный код применен!")
|
||||
await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!"))
|
||||
logger.info(f"✅ Реферальный код применен")
|
||||
else:
|
||||
await message.answer("❌ Неверный реферальный код")
|
||||
await message.answer(texts.t("REFERRAL_CODE_INVALID", "❌ Неверный реферальный код"))
|
||||
logger.info(f"❌ Неверный реферальный код")
|
||||
return
|
||||
|
||||
@@ -389,14 +499,18 @@ async def process_referral_code_input(
|
||||
|
||||
|
||||
async def process_referral_code_skip(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
|
||||
logger.info(f"⭐️ SKIP: Пропуск реферального кода от пользователя {callback.from_user.id}")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с вопросом о реферальном коде удалено")
|
||||
@@ -404,7 +518,7 @@ async def process_referral_code_skip(
|
||||
logger.warning(f"⚠️ Не удалось удалить сообщение с вопросом о реферальном коде: {e}")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"✅ Завершаем регистрацию...",
|
||||
texts.t("REGISTRATION_COMPLETING", "✅ Завершаем регистрацию..."),
|
||||
reply_markup=None
|
||||
)
|
||||
except:
|
||||
@@ -429,9 +543,14 @@ async def complete_registration_from_callback(
|
||||
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
data = await state.get_data() or {}
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await callback.message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
await callback.message.answer(
|
||||
texts.t(
|
||||
"ALREADY_REGISTERED_REFERRAL",
|
||||
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
)
|
||||
)
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
@@ -459,13 +578,18 @@ async def complete_registration_from_callback(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await callback.message.answer(f"Добро пожаловать, {existing_user.full_name}!")
|
||||
await callback.message.answer(
|
||||
texts.t(
|
||||
"WELCOME_FALLBACK",
|
||||
"Добро пожаловать, {user_name}!",
|
||||
).format(user_name=existing_user.full_name)
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
@@ -533,9 +657,35 @@ async def complete_registration_from_callback(
|
||||
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
|
||||
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
|
||||
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception as refresh_error:
|
||||
logger.error(
|
||||
"Ошибка обновления данных пользователя %s после бонуса кампании: %s",
|
||||
user.telegram_id,
|
||||
refresh_error,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(user, ["subscription"])
|
||||
except Exception as refresh_subscription_error:
|
||||
logger.error(
|
||||
"Ошибка обновления подписки пользователя %s после бонуса кампании: %s",
|
||||
user.telegram_id,
|
||||
refresh_subscription_error,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if campaign_message:
|
||||
try:
|
||||
await callback.message.answer(campaign_message)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}")
|
||||
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, callback.from_user)
|
||||
|
||||
@@ -543,7 +693,7 @@ async def complete_registration_from_callback(
|
||||
try:
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
)
|
||||
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
@@ -551,10 +701,10 @@ async def complete_registration_from_callback(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
@@ -576,7 +726,12 @@ async def complete_registration_from_callback(
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню: {e}")
|
||||
await callback.message.answer(f"Добро пожаловать, {user.full_name}!")
|
||||
await callback.message.answer(
|
||||
texts.t(
|
||||
"WELCOME_FALLBACK",
|
||||
"Добро пожаловать, {user_name}!",
|
||||
).format(user_name=user.full_name)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
|
||||
|
||||
@@ -594,9 +749,14 @@ async def complete_registration(
|
||||
logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
data = await state.get_data() or {}
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ALREADY_REGISTERED_REFERRAL",
|
||||
"ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
)
|
||||
)
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
@@ -624,13 +784,18 @@ async def complete_registration(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await message.answer(f"Добро пожаловать, {existing_user.full_name}!")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"WELCOME_FALLBACK",
|
||||
"Добро пожаловать, {user_name}!",
|
||||
).format(user_name=existing_user.full_name)
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
data = await state.get_data() or {}
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
@@ -698,9 +863,35 @@ async def complete_registration(
|
||||
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
|
||||
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
|
||||
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception as refresh_error:
|
||||
logger.error(
|
||||
"Ошибка обновления данных пользователя %s после бонуса кампании: %s",
|
||||
user.telegram_id,
|
||||
refresh_error,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(user, ["subscription"])
|
||||
except Exception as refresh_subscription_error:
|
||||
logger.error(
|
||||
"Ошибка обновления подписки пользователя %s после бонуса кампании: %s",
|
||||
user.telegram_id,
|
||||
refresh_subscription_error,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if campaign_message:
|
||||
try:
|
||||
await message.answer(campaign_message)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}")
|
||||
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, message.from_user)
|
||||
|
||||
@@ -708,7 +899,7 @@ async def complete_registration(
|
||||
try:
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
)
|
||||
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
@@ -716,10 +907,10 @@ async def complete_registration(
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
has_active_subscription = bool(getattr(user, "subscription", None))
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
|
||||
if getattr(user, "subscription", None):
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
@@ -741,54 +932,92 @@ async def complete_registration(
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню: {e}")
|
||||
await message.answer(f"Добро пожаловать, {user.full_name}!")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"WELCOME_FALLBACK",
|
||||
"Добро пожаловать, {user_name}!",
|
||||
).format(user_name=user.full_name)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
|
||||
|
||||
|
||||
def _get_subscription_status(user, texts):
|
||||
if not user or not hasattr(user, 'subscription'):
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
if not user.subscription:
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
if not user or not hasattr(user, "subscription") or not user.subscription:
|
||||
return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки")
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
end_date = getattr(subscription, "end_date", None)
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if hasattr(subscription, 'end_date') and subscription.end_date <= current_time:
|
||||
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
|
||||
|
||||
if hasattr(subscription, 'end_date'):
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
else:
|
||||
days_left = 0
|
||||
|
||||
is_trial = getattr(subscription, 'is_trial', False)
|
||||
|
||||
|
||||
if end_date and end_date <= current_time:
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'))
|
||||
|
||||
if not end_date:
|
||||
return texts.t("SUBSCRIPTION_ACTIVE", "✅ Активна")
|
||||
|
||||
days_left = (end_date - current_time).days
|
||||
is_trial = getattr(subscription, "is_trial", False)
|
||||
|
||||
if is_trial:
|
||||
if days_left > 1:
|
||||
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left == 1:
|
||||
return "🎁 Тестовая подписка\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return "🎁 Тестовая подписка\n⚠️ истекает сегодня!"
|
||||
else:
|
||||
if days_left > 7:
|
||||
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left > 1:
|
||||
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
|
||||
elif days_left == 1:
|
||||
return "💎 Активна\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return "💎 Активна\n⚠️ истекает сегодня!"
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
|
||||
)
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TODAY",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
if days_left > 7:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left)
|
||||
if days_left > 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
"💎 Активна\n⚠️ истекает через {days} дн.",
|
||||
).format(days=days_left)
|
||||
if days_left == 1:
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TOMORROW",
|
||||
"💎 Активна\n⚠️ истекает завтра!",
|
||||
)
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TODAY",
|
||||
"💎 Активна\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
|
||||
def _get_subscription_status_simple(texts):
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки")
|
||||
|
||||
|
||||
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
|
||||
if not random_message:
|
||||
return base_text
|
||||
|
||||
prompt = action_prompt or ""
|
||||
if prompt and prompt in base_text:
|
||||
parts = base_text.split(prompt, 1)
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}"
|
||||
return base_text.replace(prompt, f"\n{random_message}\n\n{prompt}", 1)
|
||||
|
||||
return f"{base_text}\n\n{random_message}"
|
||||
|
||||
|
||||
def get_referral_code_keyboard(language: str):
|
||||
@@ -797,59 +1026,47 @@ def get_referral_code_keyboard(language: str):
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="⭐️ Пропустить",
|
||||
text=texts.t("REFERRAL_CODE_SKIP", "⭐️ Пропустить"),
|
||||
callback_data="referral_skip"
|
||||
)]
|
||||
])
|
||||
|
||||
async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
|
||||
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
if "Выберите действие:" in base_text:
|
||||
parts = base_text.split("Выберите действие:")
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
|
||||
|
||||
if "Выберите действие:" in base_text:
|
||||
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
|
||||
else:
|
||||
return f"{base_text}\n\n{random_message}"
|
||||
|
||||
return _insert_random_message(base_text, random_message, action_prompt)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
|
||||
return base_text
|
||||
|
||||
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
)
|
||||
|
||||
|
||||
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
if "Выберите действие:" in base_text:
|
||||
parts = base_text.split("Выберите действие:")
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
|
||||
|
||||
if "Выберите действие:" in base_text:
|
||||
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
|
||||
else:
|
||||
return f"{base_text}\n\n{random_message}"
|
||||
|
||||
return _insert_random_message(base_text, random_message, action_prompt)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
|
||||
return base_text
|
||||
|
||||
|
||||
@@ -860,90 +1077,90 @@ async def required_sub_channel_check(
|
||||
db: AsyncSession,
|
||||
db_user=None
|
||||
):
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
try:
|
||||
state_data = await state.get_data() or {}
|
||||
|
||||
user = db_user
|
||||
if not user:
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
|
||||
if user and getattr(user, "language", None):
|
||||
language = user.language
|
||||
elif state_data.get("language"):
|
||||
language = state_data["language"]
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
chat_member = await bot.get_chat_member(
|
||||
chat_id=settings.CHANNEL_SUB_ID,
|
||||
user_id=query.from_user.id
|
||||
)
|
||||
|
||||
|
||||
if chat_member.status not in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR]:
|
||||
return await query.answer("❌ Вы не подписались на канал!", show_alert=True)
|
||||
|
||||
await query.answer("✅ Спасибо за подписку", show_alert=True)
|
||||
|
||||
return await query.answer(
|
||||
texts.t("CHANNEL_SUBSCRIBE_REQUIRED_ALERT", "❌ Вы не подписались на канал!"),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
await query.answer(
|
||||
texts.t("CHANNEL_SUBSCRIBE_THANKS", "✅ Спасибо за подписку"),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await query.message.delete()
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
from app.localization.texts import get_texts
|
||||
from app.handlers.start import get_main_menu_text
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
has_active_subscription = bool(user.subscription)
|
||||
subscription_is_active = bool(user.subscription and user.subscription.is_active)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
|
||||
keyboard = get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription,
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
caption=menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
from app.localization.texts import get_texts
|
||||
from app.keyboards.inline import get_rules_keyboard
|
||||
|
||||
language = 'ru'
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
|
||||
|
||||
state_data['language'] = language
|
||||
await state.set_data(state_data)
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
if settings.SKIP_REFERRAL_CODE:
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, query.from_user.id)
|
||||
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=query.from_user.id,
|
||||
@@ -951,44 +1168,47 @@ async def required_sub_channel_check(
|
||||
first_name=query.from_user.first_name,
|
||||
last_name=query.from_user.last_name,
|
||||
language=language,
|
||||
referral_code=referral_code
|
||||
referral_code=referral_code,
|
||||
)
|
||||
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=f"Добро пожаловать, {user.full_name}!",
|
||||
text=texts.t("WELCOME_FALLBACK", "Добро пожаловать, {user_name}!").format(user_name=user.full_name),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text="У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
text=texts.t(
|
||||
"REFERRAL_CODE_QUESTION",
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
),
|
||||
reply_markup=get_referral_code_keyboard(language),
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
else:
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
caption=texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
caption=rules_text,
|
||||
reply_markup=get_rules_keyboard(language),
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
text=rules_text,
|
||||
reply_markup=get_rules_keyboard(language),
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в required_sub_channel_check: {e}")
|
||||
await query.answer("❌ Произошла ошибка!", show_alert=True)
|
||||
|
||||
|
||||
await query.answer(f"{texts.ERROR}!", show_alert=True)
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
|
||||
+1178
-374
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_support_keyboard
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,10 +18,12 @@ async def show_support_info(
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SUPPORT_INFO,
|
||||
reply_markup=get_support_keyboard(db_user.language)
|
||||
support_info = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=support_info,
|
||||
keyboard=get_support_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+212
-8
@@ -1,4 +1,4 @@
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Tuple, Any
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
@@ -36,12 +36,18 @@ def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
|
||||
def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
@@ -55,6 +61,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"),
|
||||
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel")
|
||||
@@ -107,12 +119,26 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"),
|
||||
InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Фильтры", callback_data="admin_users_filters")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 По балансу", callback_data="admin_users_balance_filter")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
@@ -147,6 +173,142 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"),
|
||||
InlineKeyboardButton(text="➕ Создать", callback_data="admin_campaigns_create")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_campaign_management_keyboard(
|
||||
campaign_id: int, is_active: bool, language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
status_text = "🔴 Выключить" if is_active else "🟢 Включить"
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=f"admin_campaign_stats_{campaign_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=status_text,
|
||||
callback_data=f"admin_campaign_toggle_{campaign_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Редактировать",
|
||||
callback_data=f"admin_campaign_edit_{campaign_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=f"admin_campaign_delete_{campaign_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ К списку", callback_data="admin_campaigns_list"
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_campaign_edit_keyboard(
|
||||
campaign_id: int,
|
||||
*,
|
||||
is_balance_bonus: bool,
|
||||
language: str = "ru",
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard: List[List[InlineKeyboardButton]] = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Название",
|
||||
callback_data=f"admin_campaign_edit_name_{campaign_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Параметр",
|
||||
callback_data=f"admin_campaign_edit_start_{campaign_id}",
|
||||
),
|
||||
]
|
||||
]
|
||||
|
||||
if is_balance_bonus:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💰 Бонус на баланс",
|
||||
callback_data=f"admin_campaign_edit_balance_{campaign_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
keyboard.extend(
|
||||
[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📅 Длительность",
|
||||
callback_data=f"admin_campaign_edit_sub_days_{campaign_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Трафик",
|
||||
callback_data=f"admin_campaign_edit_sub_traffic_{campaign_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📱 Устройства",
|
||||
callback_data=f"admin_campaign_edit_sub_devices_{campaign_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🌍 Серверы",
|
||||
callback_data=f"admin_campaign_edit_sub_servers_{campaign_id}",
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK, callback_data=f"admin_campaign_manage_{campaign_id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"),
|
||||
InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
@@ -236,14 +398,21 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru", back_callback: str = "admin_users_list") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
|
||||
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}")
|
||||
InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.ADMIN_USER_PROMO_GROUP_BUTTON,
|
||||
callback_data=f"admin_user_promo_group_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настройка", callback_data=f"admin_user_servers_{user_id}"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
|
||||
],
|
||||
[
|
||||
@@ -267,9 +436,39 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_user_promo_group_keyboard(
|
||||
promo_groups: List[Tuple[Any, int]],
|
||||
user_id: int,
|
||||
current_group_id: Optional[int],
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
for group, members_count in promo_groups:
|
||||
prefix = "✅" if current_group_id is not None and group.id == current_group_id else "👥"
|
||||
count_text = f" ({members_count})" if members_count else ""
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{prefix} {group.name}{count_text}",
|
||||
callback_data=f"admin_user_promo_group_set_{user_id}_{group.id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.ADMIN_USER_PROMO_GROUP_BACK,
|
||||
callback_data=f"admin_user_manage_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@@ -355,7 +554,12 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring")
|
||||
InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring"),
|
||||
InlineKeyboardButton(text="🔚 Истекшие", callback_data="broadcast_expired")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🧊 Активна 0 ГБ", callback_data="broadcast_active_zero"),
|
||||
InlineKeyboardButton(text="🥶 Триал 0 ГБ", callback_data="broadcast_trial_zero")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
|
||||
|
||||
+598
-160
File diff suppressed because it is too large
Load Diff
+16
-13
@@ -33,19 +33,19 @@ def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="👥 Пользователи"),
|
||||
KeyboardButton(text="📱 Подписки")
|
||||
KeyboardButton(text=texts.ADMIN_USERS),
|
||||
KeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🎫 Промокоды"),
|
||||
KeyboardButton(text="📨 Рассылки")
|
||||
KeyboardButton(text=texts.ADMIN_PROMOCODES),
|
||||
KeyboardButton(text=texts.ADMIN_MESSAGES)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="📊 Статистика"),
|
||||
KeyboardButton(text="🔧 Мониторинг")
|
||||
KeyboardButton(text=texts.ADMIN_STATISTICS),
|
||||
KeyboardButton(text=texts.ADMIN_MONITORING)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🏠 Главное меню")
|
||||
KeyboardButton(text=texts.t("ADMIN_MAIN_MENU", "🏠 Главное меню"))
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
@@ -81,9 +81,10 @@ def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup
|
||||
|
||||
|
||||
def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="⏭️ Пропустить")]
|
||||
[KeyboardButton(text=texts.REFERRAL_CODE_SKIP)]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
@@ -95,10 +96,11 @@ def remove_keyboard() -> ReplyKeyboardRemove:
|
||||
|
||||
|
||||
def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="📱 Отправить контакт", request_contact=True)],
|
||||
[KeyboardButton(text="❌ Отмена")]
|
||||
[KeyboardButton(text=texts.t("SEND_CONTACT_BUTTON", "📱 Отправить контакт"), request_contact=True)],
|
||||
[KeyboardButton(text=texts.CANCEL)]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
@@ -106,11 +108,12 @@ def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
|
||||
|
||||
def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)],
|
||||
[KeyboardButton(text="❌ Отмена")]
|
||||
[KeyboardButton(text=texts.t("SEND_LOCATION_BUTTON", "📍 Отправить геолокацию"), request_location=True)],
|
||||
[KeyboardButton(text=texts.CANCEL)]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Sample localization for English language.
|
||||
# Keys correspond to constants defined in app/localization/texts.py.
|
||||
WELCOME: |
|
||||
Welcome to Remnawave Bedolaga Bot!
|
||||
Update this text to greet users in your own style.
|
||||
|
||||
MENU:
|
||||
BALANCE: "Balance"
|
||||
SERVER_STATUS: "Server status"
|
||||
SUBSCRIPTION: "Subscription"
|
||||
|
||||
SERVER_STATUS:
|
||||
TITLE: "📊 <b>Server status</b>"
|
||||
SUMMARY: "Total servers: {total} (online: {online}, offline: {offline})"
|
||||
AVAILABLE: "✅ <b>Online</b>"
|
||||
UNAVAILABLE: "❌ <b>Offline</b>"
|
||||
LATENCY: "{latency} ms"
|
||||
LATENCY_UNKNOWN: "no data"
|
||||
OFFLINE: "no response"
|
||||
NO_SERVERS: "No server data available."
|
||||
PAGINATION: "Page {current} of {total}"
|
||||
PREV_PAGE: "⬅️ Back"
|
||||
NEXT_PAGE: "Next ➡️"
|
||||
REFRESH: "🔄 Refresh"
|
||||
ERROR_SHORT: "Failed to fetch data"
|
||||
NOT_CONFIGURED: "Feature is not available."
|
||||
UPDATED_AT: "⏱ Updated at: {time}"
|
||||
|
||||
RULES_TEXT: |
|
||||
Remnawave service rules:
|
||||
1. Follow the law of your jurisdiction.
|
||||
2. Do not distribute spam or malicious content.
|
||||
3. Respect other community members.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Пример локализации на русском языке.
|
||||
# Ключи соответствуют именам констант из app/localization/texts.py.
|
||||
WELCOME: |
|
||||
Добро пожаловать в Remnawave Bedolaga Bot!
|
||||
Эти строки заменят стандартное приветствие бота.
|
||||
|
||||
MENU:
|
||||
BALANCE: "Баланс"
|
||||
SERVER_STATUS: "Статус серверов"
|
||||
SUBSCRIPTION: "Подписка"
|
||||
|
||||
SERVER_STATUS:
|
||||
TITLE: "📊 <b>Статус серверов</b>"
|
||||
SUMMARY: "Всего серверов: {total} (в сети: {online}, вне сети: {offline})"
|
||||
AVAILABLE: "✅ <b>Доступны</b>"
|
||||
UNAVAILABLE: "❌ <b>Недоступны</b>"
|
||||
LATENCY: "{latency} мс"
|
||||
LATENCY_UNKNOWN: "нет данных"
|
||||
OFFLINE: "нет ответа"
|
||||
NO_SERVERS: "Нет данных о серверах."
|
||||
PAGINATION: "Страница {current} из {total}"
|
||||
PREV_PAGE: "⬅️ Назад"
|
||||
NEXT_PAGE: "Вперед ➡️"
|
||||
REFRESH: "🔄 Обновить"
|
||||
ERROR_SHORT: "Не удалось получить данные"
|
||||
NOT_CONFIGURED: "Функция недоступна."
|
||||
UPDATED_AT: "⏱ Обновлено: {time}"
|
||||
|
||||
RULES_TEXT: |
|
||||
Правила сервиса Remnawave:
|
||||
1. Следуйте законам своей страны.
|
||||
2. Не распространяйте спам и вредоносный контент.
|
||||
3. Уважайте других пользователей.
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_FALLBACK_LANGUAGE = "ru"
|
||||
|
||||
_BASE_DIR = Path(__file__).resolve().parent
|
||||
_DEFAULT_LOCALES_DIR = _BASE_DIR / "locales"
|
||||
|
||||
|
||||
def _normalize_language_code(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower()
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip().lower()
|
||||
|
||||
|
||||
def _resolve_user_locales_dir() -> Path:
|
||||
path = Path(settings.LOCALES_PATH).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
return path
|
||||
|
||||
|
||||
def _locale_file_exists(language: str) -> bool:
|
||||
code = _normalize_language_code(language)
|
||||
if not code:
|
||||
return False
|
||||
|
||||
default_candidate = _DEFAULT_LOCALES_DIR / f"{code}.json"
|
||||
if default_candidate.exists():
|
||||
return True
|
||||
|
||||
user_dir = _resolve_user_locales_dir()
|
||||
for extension in (".json", ".yml", ".yaml"):
|
||||
if (user_dir / f"{code}{extension}").exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _select_fallback_language(available_map: Dict[str, str]) -> str:
|
||||
candidates = []
|
||||
if _FALLBACK_LANGUAGE:
|
||||
candidates.append(_FALLBACK_LANGUAGE)
|
||||
candidates.extend(available_map.values())
|
||||
|
||||
seen = set()
|
||||
for candidate in candidates:
|
||||
normalized = _normalize_language_code(candidate)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
|
||||
if normalized in available_map:
|
||||
return available_map[normalized]
|
||||
|
||||
if _locale_file_exists(normalized):
|
||||
return normalized
|
||||
|
||||
if _FALLBACK_LANGUAGE and _locale_file_exists(_FALLBACK_LANGUAGE):
|
||||
return _FALLBACK_LANGUAGE
|
||||
|
||||
return _FALLBACK_LANGUAGE or "ru"
|
||||
|
||||
|
||||
def _determine_default_language() -> str:
|
||||
try:
|
||||
raw_default = settings.DEFAULT_LANGUAGE
|
||||
except AttributeError:
|
||||
raw_default = None
|
||||
|
||||
configured = raw_default.strip() if isinstance(raw_default, str) else ""
|
||||
|
||||
try:
|
||||
available_languages = settings.get_available_languages()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
_logger.warning("Failed to load available languages from settings: %s", error)
|
||||
available_languages = []
|
||||
|
||||
available_map = {
|
||||
_normalize_language_code(lang): lang.strip()
|
||||
for lang in available_languages
|
||||
if isinstance(lang, str) and lang.strip()
|
||||
}
|
||||
|
||||
if configured:
|
||||
normalized_configured = _normalize_language_code(configured)
|
||||
|
||||
if normalized_configured in available_map:
|
||||
return available_map[normalized_configured]
|
||||
|
||||
if not available_map and _locale_file_exists(normalized_configured):
|
||||
return normalized_configured
|
||||
|
||||
if _locale_file_exists(normalized_configured):
|
||||
_logger.warning(
|
||||
"Configured default language '%s' is not listed in AVAILABLE_LANGUAGES. Falling back to '%s'.",
|
||||
configured,
|
||||
_FALLBACK_LANGUAGE,
|
||||
)
|
||||
else:
|
||||
_logger.warning(
|
||||
"Configured default language '%s' is not available. Falling back to '%s'.",
|
||||
configured,
|
||||
_FALLBACK_LANGUAGE,
|
||||
)
|
||||
else:
|
||||
_logger.debug("DEFAULT_LANGUAGE is not set. Falling back to '%s'.", _FALLBACK_LANGUAGE)
|
||||
|
||||
fallback_language = _select_fallback_language(available_map)
|
||||
|
||||
if _normalize_language_code(fallback_language) != _normalize_language_code(_FALLBACK_LANGUAGE):
|
||||
_logger.warning(
|
||||
"Fallback language '%s' is not available. Using '%s' instead.",
|
||||
_FALLBACK_LANGUAGE,
|
||||
fallback_language,
|
||||
)
|
||||
|
||||
return fallback_language or _FALLBACK_LANGUAGE
|
||||
|
||||
|
||||
DEFAULT_LANGUAGE = _determine_default_language()
|
||||
|
||||
|
||||
def _normalize_key(raw_key: Any) -> str:
|
||||
key = str(raw_key).strip().replace(" ", "_")
|
||||
return key.upper()
|
||||
|
||||
|
||||
def _flatten_locale_dict(data: Dict[str, Any], parent_key: str = "") -> Dict[str, Any]:
|
||||
flattened: Dict[str, Any] = {}
|
||||
for key, value in (data or {}).items():
|
||||
composite_key = _normalize_key(key)
|
||||
if parent_key:
|
||||
composite_key = f"{parent_key}_{composite_key}"
|
||||
|
||||
if isinstance(value, dict):
|
||||
flattened.update(_flatten_locale_dict(value, composite_key))
|
||||
else:
|
||||
flattened[composite_key] = value
|
||||
return flattened
|
||||
|
||||
|
||||
def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
normalized: Dict[str, Any] = {}
|
||||
for key, value in (data or {}).items():
|
||||
if isinstance(value, dict):
|
||||
normalized.update(_flatten_locale_dict(value, _normalize_key(key)))
|
||||
else:
|
||||
normalized[_normalize_key(key)] = value
|
||||
return normalized
|
||||
|
||||
|
||||
def ensure_locale_templates() -> None:
|
||||
destination = _resolve_user_locales_dir()
|
||||
try:
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as error:
|
||||
_logger.warning("Unable to create locales directory %s: %s", destination, error)
|
||||
return
|
||||
|
||||
if any(destination.glob("*")):
|
||||
return
|
||||
|
||||
if not _DEFAULT_LOCALES_DIR.exists():
|
||||
_logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR)
|
||||
return
|
||||
|
||||
for template in _DEFAULT_LOCALES_DIR.iterdir():
|
||||
if not template.is_file():
|
||||
continue
|
||||
target_path = destination / template.name
|
||||
try:
|
||||
shutil.copyfile(template, target_path)
|
||||
except Exception as error:
|
||||
_logger.warning(
|
||||
"Failed to copy default locale %s to %s: %s",
|
||||
template,
|
||||
target_path,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
def _load_default_locale(language: str) -> Dict[str, Any]:
|
||||
default_path = _DEFAULT_LOCALES_DIR / f"{language}.json"
|
||||
if not default_path.exists():
|
||||
return {}
|
||||
return _normalize_locale_dict(_load_locale_file(default_path))
|
||||
|
||||
|
||||
def _load_user_locale(language: str) -> Dict[str, Any]:
|
||||
user_dir = _resolve_user_locales_dir()
|
||||
for extension in (".json", ".yml", ".yaml"):
|
||||
candidate = user_dir / f"{language}{extension}"
|
||||
if candidate.exists():
|
||||
return _normalize_locale_dict(_load_locale_file(candidate))
|
||||
return {}
|
||||
|
||||
|
||||
def _load_locale_file(path: Path) -> Dict[str, Any]:
|
||||
suffix = path.suffix.lower()
|
||||
try:
|
||||
if suffix == ".json":
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
if suffix in {".yml", ".yaml"}:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ModuleNotFoundError as import_error:
|
||||
raise RuntimeError(
|
||||
"PyYAML is required to load YAML locale files. Install PyYAML or provide JSON files."
|
||||
) from import_error
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as error:
|
||||
_logger.warning("Failed to parse locale file %s: %s", path, error)
|
||||
return {}
|
||||
|
||||
_logger.warning("Unsupported locale format for %s", path)
|
||||
return {}
|
||||
|
||||
|
||||
def _merge_dicts(base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
|
||||
result = dict(base)
|
||||
for key, value in overrides.items():
|
||||
if (
|
||||
key in result
|
||||
and isinstance(result[key], dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
result[key] = _merge_dicts(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def load_locale(language: str) -> Dict[str, Any]:
|
||||
language = language or DEFAULT_LANGUAGE
|
||||
defaults = _load_default_locale(language)
|
||||
overrides = _load_user_locale(language)
|
||||
merged = _merge_dicts(defaults, overrides)
|
||||
|
||||
if not merged and language != DEFAULT_LANGUAGE:
|
||||
_logger.warning(
|
||||
"Locale %s not found. Falling back to default language %s.",
|
||||
language,
|
||||
DEFAULT_LANGUAGE,
|
||||
)
|
||||
return load_locale(DEFAULT_LANGUAGE)
|
||||
return merged
|
||||
|
||||
|
||||
def clear_locale_cache() -> None:
|
||||
load_locale.cache_clear()
|
||||
@@ -0,0 +1,406 @@
|
||||
{
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"ADMIN_MAIN_MENU": "🏠 Main menu",
|
||||
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
|
||||
"AUTOPAY_BUTTON": "💳 Auto payment",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
|
||||
"BACK": "⬅️ Back",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
|
||||
"CANCEL": "❌ Cancel",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
|
||||
"CHECK_STATUS_BUTTON": "📊 Check status",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
|
||||
"CONFIRM": "✅ Confirm",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
|
||||
"CONNECT_BUTTON": "🔗 Connect",
|
||||
"CONTINUE": "➡️ Continue",
|
||||
"CONTINUE_BUTTON": "➡️ Continue",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
|
||||
"CREATE_INVITE_BUTTON": "📝 Create invite",
|
||||
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
"DISABLE_BUTTON": "❌ Disable",
|
||||
"ENABLE_BUTTON": "✅ Enable",
|
||||
"ERROR": "❌ An error occurred",
|
||||
"ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.",
|
||||
"ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:",
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"MENU_BALANCE": "💰 Balance",
|
||||
"MENU_SUBSCRIPTION": "📱 Subscription",
|
||||
"MENU_TRIAL": "🎁 Trial subscription",
|
||||
"MY_BALANCE_BUTTON": "💰 My balance",
|
||||
"MY_SUBSCRIPTION_BUTTON": "📱 My subscription",
|
||||
"NO": "❌ No",
|
||||
"NO_SERVERS_AVAILABLE": "❌ No servers available",
|
||||
"NO_TRAFFIC_PACKAGES": "❌ No packages available",
|
||||
"OTHER_APPS_BUTTON": "📋 Other apps",
|
||||
"PAGINATION_NEXT": "➡️",
|
||||
"PAGINATION_PREV": "⬅️",
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
|
||||
"PAY_NOW_BUTTON": "💳 Pay",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
|
||||
"REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!",
|
||||
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Skip",
|
||||
"ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Referral list",
|
||||
"RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices",
|
||||
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device",
|
||||
"RESET_TRAFFIC_BUTTON": "🔄 Reset traffic",
|
||||
"RULES_HEADER": "📋 <b>Service Rules</b>",
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
|
||||
"SEND_CONTACT_BUTTON": "📱 Share contact",
|
||||
"SEND_LOCATION_BUTTON": "📍 Share location",
|
||||
"SHOW_QR_BUTTON": "📱 Show QR code",
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link",
|
||||
"SKIP_BUTTON": "Skip ➡️",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days",
|
||||
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
|
||||
"SUB_STATUS_NONE": "❌ Not available",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
|
||||
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!",
|
||||
"SUBSCRIPTION_ACTIVE": "✅ Active",
|
||||
"SUCCESS": "✅ Success",
|
||||
"REGISTRATION_COMPLETING": "✅ Completing registration...",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Top up balance",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
|
||||
"PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
|
||||
"STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.",
|
||||
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
|
||||
"WELCOME_FALLBACK": "Welcome, {user_name}!",
|
||||
"YES": "✅ Yes",
|
||||
"ACCESS_DENIED": "❌ Access denied",
|
||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||
"ADMIN_MONITORING": "🔍 Monitoring",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Administration panel</b>\n\nSelect a section to manage:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Promo codes",
|
||||
"ADMIN_REFERRALS": "🤝 Referral program",
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Rules",
|
||||
"ADMIN_STATISTICS": "📊 Statistics",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>User promo group</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.",
|
||||
"ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:",
|
||||
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”",
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Promo group:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_USERS": "👥 Users",
|
||||
"AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically",
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Autopay failed</b>\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
|
||||
"BALANCE_BUTTON": "💰 Balance: {balance}",
|
||||
"BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽",
|
||||
"BALANCE_HISTORY": "📊 Transaction history",
|
||||
"BALANCE_INFO": "\n💰 <b>Balance: {balance}</b>\n\nChoose an action:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
|
||||
"BALANCE_TOP_UP": "💳 Top up",
|
||||
"CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.",
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Subscription setup</b>\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n",
|
||||
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Your promo group discounts</b>",
|
||||
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n",
|
||||
"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",
|
||||
"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})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed",
|
||||
"INVALID_AMOUNT": "❌ Invalid amount",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n",
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n",
|
||||
"MENU_ADMIN": "⚙️ Admin panel",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
"MENU_SERVER_STATUS": "📊 Server status",
|
||||
"MENU_SUPPORT": "🛠️ Support",
|
||||
"OPERATION_CANCELLED": "❌ Operation cancelled",
|
||||
"PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"PROMOCODE_ENTER": "🎫 Enter promo code",
|
||||
"PROMOCODE_EXPIRED": "❌ Promo code has expired",
|
||||
"PROMOCODE_INVALID": "❌ Invalid promo code",
|
||||
"PROMOCODE_SUCCESS": "🎉 Promo code applied!",
|
||||
"PROMOCODE_USED": "ℹ️ Promo code has already been used",
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Referral program</b>\n\n👥 <b>Invited:</b> {referrals_count} friends\n💰 <b>Earned:</b> {earned_amount}\n\n🔗 <b>Your referral link:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Your promo code:</b>\n<code>{referral_code}</code>\n\n💰 <b>Terms:</b>\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Invitation to the VPN service</b>\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n",
|
||||
"RULES_ACCEPT": "✅ I accept the rules",
|
||||
"RULES_DECLINE": "❌ I do not accept",
|
||||
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
|
||||
"SELECT_COUNTRIES": "Select countries:",
|
||||
"SELECT_DEVICES": "Number of devices:",
|
||||
"SELECT_PERIOD": "Choose period:",
|
||||
"SELECT_TRAFFIC": "Choose traffic package:",
|
||||
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew it to restore access.\n",
|
||||
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Subscription expiring!</b>\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Subscription expires in {days_text}!</b>\n\nYour paid subscription ends on {end_date}.\n\n💳 <b>Autopay:</b> {autopay_status}\n\n{action_text}\n",
|
||||
"SUBSCRIPTION_INFO": "\n📱 <b>Subscription details</b>\n\n📊 <b>Status:</b> {status}\n🎭 <b>Type:</b> {type}\n📅 <b>Valid until:</b> {end_date}\n⏰ <b>Days left:</b> {days_left}\n\n📈 <b>Traffic:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Servers:</b> {countries_count} countries\n📱 <b>Devices:</b> {devices_used} / {devices_limit}\n\n💳 <b>Autopay:</b> {autopay_status}\n",
|
||||
"SUBSCRIPTION_NONE": "❌ No active subscription",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Final configuration</b>\n\n📅 <b>Period:</b> {period} days\n📈 <b>Traffic:</b> {traffic}\n🌍 <b>Countries:</b> {countries}\n📱 <b>Devices:</b> {devices}\n\n💰 <b>Total:</b> {total_price}\n\nConfirm the purchase?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Trial subscription",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Technical support</b>\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n",
|
||||
"SERVER_STATUS_AVAILABLE": "✅ <b>Online</b>",
|
||||
"SERVER_STATUS_ERROR_SHORT": "Failed to fetch data",
|
||||
"SERVER_STATUS_LATENCY": "{latency} ms",
|
||||
"SERVER_STATUS_LATENCY_UNKNOWN": "no data",
|
||||
"SERVER_STATUS_NEXT_PAGE": "Next ➡️",
|
||||
"SERVER_STATUS_NO_SERVERS": "No server data available.",
|
||||
"SERVER_STATUS_NOT_CONFIGURED": "Feature is not available.",
|
||||
"SERVER_STATUS_OFFLINE": "no response",
|
||||
"SERVER_STATUS_PAGINATION": "Page {current} of {total}",
|
||||
"SERVER_STATUS_PREV_PAGE": "⬅️ Back",
|
||||
"SERVER_STATUS_REFRESH": "🔄 Refresh",
|
||||
"SERVER_STATUS_SUMMARY": "Total servers: {total} (online: {online}, offline: {offline})",
|
||||
"SERVER_STATUS_TITLE": "📊 <b>Server status</b>",
|
||||
"SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}",
|
||||
"SERVER_STATUS_UNAVAILABLE": "❌ <b>Offline</b>",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Confirm traffic change</b>\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Switch traffic limit</b>\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 <b>Important:</b>\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n",
|
||||
"SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit",
|
||||
"TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):",
|
||||
"TOP_UP_METHODS": "\n💳 <b>Select a payment method</b>\n\nAmount: {amount}\n",
|
||||
"TOP_UP_STARS": "⭐ Telegram Stars",
|
||||
"TOP_UP_TRIBUTE": "💎 Bank card",
|
||||
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"USER_NOT_FOUND": "❌ User not found",
|
||||
"MENU_LANGUAGE": "🌐 Language",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
|
||||
"SUBSCRIPTION_STATUS_TRIAL": "Trial",
|
||||
"SUBSCRIPTION_STATUS_ACTIVE": "Active",
|
||||
"SUBSCRIPTION_STATUS_UNKNOWN": "Unknown",
|
||||
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired",
|
||||
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days",
|
||||
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr",
|
||||
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min",
|
||||
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!",
|
||||
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!",
|
||||
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!",
|
||||
"SUBSCRIPTION_TYPE_TRIAL": "Trial",
|
||||
"SUBSCRIPTION_TYPE_PAID": "Paid",
|
||||
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB",
|
||||
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB",
|
||||
"SUBSCRIPTION_NO_SERVERS": "No servers",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Connected devices:</b>\n",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Your import link for the VPN app:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.",
|
||||
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Step 2 - Add subscription:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Step 3 - Connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>How to connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Apps for {device_name}</b>",
|
||||
"SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:",
|
||||
"SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found",
|
||||
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
|
||||
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>How to use:</b>",
|
||||
"SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it",
|
||||
"SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app",
|
||||
"SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option",
|
||||
"SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link",
|
||||
"SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.",
|
||||
"REFERRAL_PROGRAM_TITLE": "👥 <b>Referral program</b>",
|
||||
"REFERRAL_STATS_HEADER": "📊 <b>Your statistics:</b>",
|
||||
"REFERRAL_STATS_INVITED": "• Invited users: <b>{count}</b>",
|
||||
"REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: <b>{count}</b>",
|
||||
"REFERRAL_STATS_ACTIVE": "• Active referrals: <b>{count}</b>",
|
||||
"REFERRAL_STATS_CONVERSION": "• Conversion: <b>{rate}%</b>",
|
||||
"REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: <b>{amount}</b>",
|
||||
"REFERRAL_STATS_MONTH_EARNED": "• Earned last month: <b>{amount}</b>",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>How rewards work:</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• New user receives: <b>{bonus}</b> on the first top-up from <b>{minimum}</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: <b>{percent}%</b>",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
|
||||
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Latest rewards:</b>",
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission",
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> from {referral_name}",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
|
||||
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
|
||||
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
|
||||
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
|
||||
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}",
|
||||
"REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}",
|
||||
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago",
|
||||
"REFERRAL_LIST_PREV_PAGE": "⬅️ Back",
|
||||
"REFERRAL_LIST_NEXT_PAGE": "Next ➡️",
|
||||
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Referral analytics</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Earnings by period:</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}",
|
||||
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Top {count} referrals:</b>",
|
||||
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)",
|
||||
"REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
|
||||
"REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection",
|
||||
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Share",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Invitation created!</b>",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
{
|
||||
"ACCESS_DENIED": "❌ Доступ запрещен",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
|
||||
"ADMIN_MAIN_MENU": "🏠 Главное меню",
|
||||
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
"ADMIN_MONITORING": "🔍 Мониторинг",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Административная панель</b>\n\nВыберите раздел для управления:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Промокоды",
|
||||
"ADMIN_REFERRALS": "🤝 Партнерка",
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Правила",
|
||||
"ADMIN_STATISTICS": "📊 Статистика",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>Промогруппа пользователя</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.",
|
||||
"ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:",
|
||||
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»",
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Промогруппа:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_USERS": "👥 Пользователи",
|
||||
"AUTOPAY_BUTTON": "💳 Автоплатёж",
|
||||
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
|
||||
"BALANCE_BUTTON": "💰 Баланс: {balance}",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}",
|
||||
"BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽",
|
||||
"BALANCE_HISTORY": "📊 История операций",
|
||||
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Настройка подписки</b>\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n",
|
||||
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Скидки вашей промогруппы</b>",
|
||||
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CANCEL": "❌ Отмена",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
|
||||
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
|
||||
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
|
||||
"CONFIRM": "✅ Подтвердить",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
|
||||
"CONNECT_BUTTON": "🔗 Подключиться",
|
||||
"CONTACT_SUPPORT": "💬 Написать в поддержку",
|
||||
"CONTINUE": "➡️ Продолжить",
|
||||
"CONTINUE_BUTTON": "✅ Продолжить",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
|
||||
"CREATE_INVITE": "📝 Создать приглашение",
|
||||
"CREATE_INVITE_BUTTON": "📝 Создать приглашение",
|
||||
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось",
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
"DISABLE_BUTTON": "❌ Выключить",
|
||||
"ENABLE_BUTTON": "✅ Включить",
|
||||
"ERROR": "❌ Произошла ошибка",
|
||||
"ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.",
|
||||
"ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:",
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
|
||||
"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_BUTTON": "🏠 Главное меню",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
|
||||
"MENU_ADMIN": "⚙️ Админ-панель",
|
||||
"MENU_BALANCE": "💰 Баланс",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
"MENU_RULES": "📋 Правила сервиса",
|
||||
"MENU_SERVER_STATUS": "📊 Статус серверов",
|
||||
"MENU_SUBSCRIPTION": "📱 Подписка",
|
||||
"MENU_SUPPORT": "🛠️ Техподдержка",
|
||||
"MENU_TRIAL": "🧪 Тестовая подписка",
|
||||
"MY_BALANCE_BUTTON": "💰 Мой баланс",
|
||||
"MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка",
|
||||
"NO": "❌ Нет",
|
||||
"NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов",
|
||||
"NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов",
|
||||
"OPERATION_CANCELLED": "❌ Операция отменена",
|
||||
"OTHER_APPS_BUTTON": "📋 Другие приложения",
|
||||
"PAGINATION_NEXT": "➡️",
|
||||
"PAGINATION_PREV": "⬅️",
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
|
||||
"PAY_NOW_BUTTON": "💳 Оплатить",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀",
|
||||
"PROMOCODE_ENTER": "🎫 Введите промокод:",
|
||||
"PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод",
|
||||
"PROMOCODE_EXPIRED": "❌ Промокод истек",
|
||||
"PROMOCODE_INVALID": "❌ Неверный промокод",
|
||||
"PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}",
|
||||
"PROMOCODE_USED": "❌ Промокод уже использован",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика",
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
|
||||
"REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!",
|
||||
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
|
||||
"ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Реферальная программа</b>\n\n👥 <b>Приглашено:</b> {referrals_count} друзей\n💰 <b>Заработано:</b> {earned_amount}\n\n🔗 <b>Ваша реферальная ссылка:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Условия:</b>\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в VPN сервис</b>\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
|
||||
"RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства",
|
||||
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство",
|
||||
"RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик",
|
||||
"RULES_ACCEPT": "✅ Принимаю правила",
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
|
||||
"RULES_DECLINE": "❌ Не принимаю",
|
||||
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
|
||||
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
|
||||
"SELECT_COUNTRIES": "Выберите страны:",
|
||||
"SELECT_DEVICES": "Количество устройств:",
|
||||
"SELECT_PERIOD": "Выберите период:",
|
||||
"SELECT_TRAFFIC": "Выберите пакет трафика:",
|
||||
"SEND_CONTACT_BUTTON": "📱 Отправить контакт",
|
||||
"SEND_LOCATION_BUTTON": "📍 Отправить геолокацию",
|
||||
"SHOW_QR_BUTTON": "📱 Показать QR код",
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки",
|
||||
"SKIP_BUTTON": "⏭️ Пропустить",
|
||||
"SUBSCRIPTION_ACTIVE": "✅ Активна",
|
||||
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Подписка истекла</b>\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n",
|
||||
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Подписка истекает!</b>\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\nВаша платная подписка истекает {end_date}.\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n\n{action_text}\n",
|
||||
"SUBSCRIPTION_INFO": "\n📱 <b>Информация о подписке</b>\n\n📊 <b>Статус:</b> {status}\n🎭 <b>Тип:</b> {type}\n📅 <b>Действует до:</b> {end_date}\n⏰ <b>Осталось дней:</b> {days_left}\n\n📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Серверы:</b> {countries_count} стран\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n",
|
||||
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.",
|
||||
"SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
|
||||
"SUB_STATUS_NONE": "❌ Отсутствует",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!",
|
||||
"SUCCESS": "✅ Успешно",
|
||||
"REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Техническая поддержка</b>\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n",
|
||||
"SERVER_STATUS_AVAILABLE": "✅ <b>Доступны</b>",
|
||||
"SERVER_STATUS_ERROR_SHORT": "Не удалось получить данные",
|
||||
"SERVER_STATUS_LATENCY": "{latency} мс",
|
||||
"SERVER_STATUS_LATENCY_UNKNOWN": "нет данных",
|
||||
"SERVER_STATUS_NEXT_PAGE": "Вперед ➡️",
|
||||
"SERVER_STATUS_NO_SERVERS": "Нет данных о серверах.",
|
||||
"SERVER_STATUS_NOT_CONFIGURED": "Функция недоступна.",
|
||||
"SERVER_STATUS_OFFLINE": "нет ответа",
|
||||
"SERVER_STATUS_PAGINATION": "Страница {current} из {total}",
|
||||
"SERVER_STATUS_PREV_PAGE": "⬅️ Назад",
|
||||
"SERVER_STATUS_REFRESH": "🔄 Обновить",
|
||||
"SERVER_STATUS_SUMMARY": "Всего серверов: {total} (в сети: {online}, вне сети: {offline})",
|
||||
"SERVER_STATUS_TITLE": "📊 <b>Статус серверов</b>",
|
||||
"SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}",
|
||||
"SERVER_STATUS_UNAVAILABLE": "❌ <b>Недоступны</b>",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Подтверждение переключения трафика</b>\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Переключение лимита трафика</b>\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n",
|
||||
"SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс",
|
||||
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
|
||||
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
|
||||
"TOP_UP_STARS": "⭐ Telegram Stars",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
|
||||
"STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.",
|
||||
"TOP_UP_TRIBUTE": "💎 Банковская карта",
|
||||
"TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
|
||||
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
"USER_NOT_FOUND": "❌ Пользователь не найден",
|
||||
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
|
||||
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
|
||||
"YES": "✅ Да",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Истекла",
|
||||
"SUBSCRIPTION_STATUS_TRIAL": "Тестовая",
|
||||
"SUBSCRIPTION_STATUS_ACTIVE": "Активна",
|
||||
"SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно",
|
||||
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк",
|
||||
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.",
|
||||
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.",
|
||||
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.",
|
||||
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!",
|
||||
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!",
|
||||
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!",
|
||||
"SUBSCRIPTION_TYPE_TRIAL": "Триал",
|
||||
"SUBSCRIPTION_TYPE_PAID": "Платная",
|
||||
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ",
|
||||
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ",
|
||||
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Подключенные устройства:</b>\n",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
|
||||
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Шаг 2 - Добавление подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Шаг 3 - Подключение:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>Как подключить:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Приложения для {device_name}</b>",
|
||||
"SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:",
|
||||
"SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено",
|
||||
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
|
||||
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>Как использовать:</b>",
|
||||
"SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать",
|
||||
"SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение",
|
||||
"SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
|
||||
"SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку",
|
||||
"SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
|
||||
"REFERRAL_PROGRAM_TITLE": "👥 <b>Реферальная программа</b>",
|
||||
"REFERRAL_STATS_HEADER": "📊 <b>Ваша статистика:</b>",
|
||||
"REFERRAL_STATS_INVITED": "• Приглашено пользователей: <b>{count}</b>",
|
||||
"REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: <b>{count}</b>",
|
||||
"REFERRAL_STATS_ACTIVE": "• Активных рефералов: <b>{count}</b>",
|
||||
"REFERRAL_STATS_CONVERSION": "• Конверсия: <b>{rate}%</b>",
|
||||
"REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: <b>{amount}</b>",
|
||||
"REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: <b>{amount}</b>",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>Как работают награды:</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
|
||||
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Последние начисления:</b>",
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки",
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> от {referral_name}",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
|
||||
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
|
||||
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
|
||||
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
|
||||
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}",
|
||||
"REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}",
|
||||
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно",
|
||||
"REFERRAL_LIST_PREV_PAGE": "⬅️ Назад",
|
||||
"REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️",
|
||||
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Аналитика рефералов</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Доходы по периодам:</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}",
|
||||
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Топ-{count} рефералов:</b>",
|
||||
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)",
|
||||
"REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
|
||||
"REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение",
|
||||
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Поделиться",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Приглашение создано!</b>",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
|
||||
}
|
||||
+186
-569
@@ -1,603 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.loader import (
|
||||
DEFAULT_LANGUAGE,
|
||||
clear_locale_cache,
|
||||
load_locale,
|
||||
)
|
||||
|
||||
_cached_rules = {}
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def _get_default_rules(language: str = "ru") -> str:
|
||||
if language == "en":
|
||||
return """
|
||||
🔒 <b>Service Usage Rules</b>
|
||||
_cached_rules: Dict[str, str] = {}
|
||||
|
||||
1. It is forbidden to use the service for illegal activities
|
||||
2. Copyright infringement is prohibited
|
||||
3. Spam and malware distribution are prohibited
|
||||
4. Using the service for DDoS attacks is prohibited
|
||||
5. One account - one user
|
||||
6. Refunds are made only in exceptional cases
|
||||
7. Administration reserves the right to block an account for violating the rules
|
||||
|
||||
<b>By accepting the rules, you agree to comply with them.</b>
|
||||
"""
|
||||
else:
|
||||
return """
|
||||
📋 <b>Правила использования сервиса</b>
|
||||
def _get_cached_rules_value(language: str) -> str:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
1. Запрещается использование сервиса для незаконной деятельности
|
||||
2. Запрещается нарушение авторских прав
|
||||
3. Запрещается спам и рассылка вредоносного ПО
|
||||
4. Запрещается использование сервиса для DDoS атак
|
||||
5. Один аккаунт - один пользователь
|
||||
6. Возврат средств производится только в исключительных случаях
|
||||
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
|
||||
default = _get_default_rules(language)
|
||||
_cached_rules[language] = default
|
||||
return default
|
||||
|
||||
|
||||
def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
|
||||
if language_code == "ru":
|
||||
return {
|
||||
"PERIOD_14_DAYS": f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_100GB": f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"SUPPORT_INFO": (
|
||||
"\n🛟 <b>Поддержка</b>\n\n"
|
||||
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
|
||||
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
|
||||
"• 📋 Мои тикеты — статус и переписка\n"
|
||||
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
|
||||
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
|
||||
),
|
||||
}
|
||||
|
||||
if language_code == "en":
|
||||
return {
|
||||
"PERIOD_14_DAYS": f"📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": f"📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": f"📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": f"📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": f"📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": f"📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": f"📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_100GB": f"📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"SUPPORT_INFO": (
|
||||
"\n🛟 <b>RemnaWave Support</b>\n\n"
|
||||
"This is the ticket center: create requests, view replies and history.\n\n"
|
||||
"• 🎫 Create ticket — describe your issue or question\n"
|
||||
"• 📋 My tickets — status and conversation\n"
|
||||
"• 💬 Contact — message directly if needed\n\n"
|
||||
"Prefer tickets — it helps us respond faster and keep context.\n"
|
||||
),
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
|
||||
"""
|
||||
|
||||
class Texts:
|
||||
def __init__(self, language: str = "ru"):
|
||||
self.language = language
|
||||
|
||||
@property
|
||||
def RULES_TEXT(self) -> str:
|
||||
if self.language in _cached_rules:
|
||||
return _cached_rules[self.language]
|
||||
|
||||
return _get_default_rules(self.language)
|
||||
|
||||
BACK = "⬅️ Назад"
|
||||
CANCEL = "❌ Отмена"
|
||||
CONFIRM = "✅ Подтвердить"
|
||||
CONTINUE = "➡️ Продолжить"
|
||||
YES = "✅ Да"
|
||||
NO = "❌ Нет"
|
||||
LOADING = "⏳ Загрузка..."
|
||||
ERROR = "❌ Произошла ошибка"
|
||||
SUCCESS = "✅ Успешно"
|
||||
|
||||
def __init__(self, language: str = DEFAULT_LANGUAGE):
|
||||
self.language = language or DEFAULT_LANGUAGE
|
||||
raw_data = load_locale(self.language)
|
||||
self._values = {key: value for key, value in raw_data.items()}
|
||||
|
||||
if self.language != DEFAULT_LANGUAGE:
|
||||
fallback_data = load_locale(DEFAULT_LANGUAGE)
|
||||
else:
|
||||
fallback_data = self._values
|
||||
|
||||
self._fallback_values = {
|
||||
key: value for key, value in fallback_data.items() if key not in self._values
|
||||
}
|
||||
|
||||
self._values.update(_build_dynamic_values(self.language))
|
||||
|
||||
def __getattr__(self, item: str) -> Any:
|
||||
if item == "language":
|
||||
return super().__getattribute__(item)
|
||||
try:
|
||||
return self._get_value(item)
|
||||
except KeyError as error:
|
||||
raise AttributeError(item) from error
|
||||
|
||||
def __getitem__(self, item: str) -> Any:
|
||||
return self._get_value(item)
|
||||
|
||||
def get(self, item: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return self._get_value(item)
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def t(self, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return self._get_value(key)
|
||||
except KeyError:
|
||||
if default is not None:
|
||||
return default
|
||||
raise
|
||||
|
||||
def _get_value(self, item: str) -> Any:
|
||||
if item == "RULES_TEXT":
|
||||
return _get_cached_rules_value(self.language)
|
||||
|
||||
if item in self._values:
|
||||
return self._values[item]
|
||||
|
||||
if item in self._fallback_values:
|
||||
return self._fallback_values[item]
|
||||
|
||||
_logger.warning(
|
||||
"Missing localization key '%s' for language '%s'",
|
||||
item,
|
||||
self.language,
|
||||
)
|
||||
raise KeyError(item)
|
||||
|
||||
@staticmethod
|
||||
def format_price(kopeks: int) -> str:
|
||||
return f"{int(kopeks / 100)} ₽"
|
||||
|
||||
return settings.format_price(kopeks)
|
||||
|
||||
@staticmethod
|
||||
def format_traffic(gb: float) -> str:
|
||||
if gb == 0:
|
||||
return "∞ (безлимит)"
|
||||
elif gb >= 1024:
|
||||
return f"{gb/1024:.1f} ТБ"
|
||||
else:
|
||||
return f"{gb:.0f} ГБ"
|
||||
if gb >= 1024:
|
||||
return f"{gb / 1024:.1f} ТБ"
|
||||
return f"{gb:.0f} ГБ"
|
||||
|
||||
|
||||
class RussianTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("ru")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Добро пожаловать в VPN сервис!</b>
|
||||
def get_texts(language: str = DEFAULT_LANGUAGE) -> Texts:
|
||||
return Texts(language)
|
||||
|
||||
Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.
|
||||
|
||||
🔐 <b>Преимущества:</b>
|
||||
• Высокая скорость подключения
|
||||
• Серверы в разных странах
|
||||
• Надежная защита данных
|
||||
• Круглосуточная поддержка
|
||||
|
||||
Для начала работы выберите язык интерфейса:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: <b>Русский</b>"
|
||||
|
||||
RULES_ACCEPT = "✅ Принимаю правила"
|
||||
RULES_DECLINE = "❌ Не принимаю"
|
||||
RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!"
|
||||
|
||||
REFERRAL_CODE_QUESTION = """
|
||||
🤝 <b>У вас есть реферальный код от друга?</b>
|
||||
|
||||
Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!
|
||||
|
||||
Введите код или нажмите "Пропустить":
|
||||
"""
|
||||
|
||||
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
|
||||
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
|
||||
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
|
||||
|
||||
MAIN_MENU = """👤 <b>{user_name}</b>
|
||||
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
MENU_BALANCE = "💰 Баланс"
|
||||
MENU_SUBSCRIPTION = "📱 Подписка"
|
||||
MENU_TRIAL = "🧪 Тестовая подписка"
|
||||
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
|
||||
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
|
||||
MENU_PROMOCODE = "🎫 Промокод"
|
||||
MENU_REFERRALS = "🤝 Партнерка"
|
||||
MENU_SUPPORT = "🛠️ Техподдержка"
|
||||
MENU_RULES = "📋 Правила сервиса"
|
||||
MENU_LANGUAGE = "🌐 Язык"
|
||||
MENU_ADMIN = "⚙️ Админ-панель"
|
||||
BALANCE_BUTTON = "💰 Баланс: {balance}"
|
||||
BALANCE_BUTTON_ZERO = "💰 Баланс: 0 ₽"
|
||||
|
||||
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
|
||||
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
|
||||
SUBSCRIPTION_ACTIVE = "✅ Активна"
|
||||
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
|
||||
|
||||
SUBSCRIPTION_INFO = """
|
||||
📱 <b>Информация о подписке</b>
|
||||
|
||||
📊 <b>Статус:</b> {status}
|
||||
🎭 <b>Тип:</b> {type}
|
||||
📅 <b>Действует до:</b> {end_date}
|
||||
⏰ <b>Осталось дней:</b> {days_left}
|
||||
|
||||
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
|
||||
🌍 <b>Серверы:</b> {countries_count} стран
|
||||
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
"""
|
||||
|
||||
TRIAL_AVAILABLE = """
|
||||
🎁 <b>Тестовая подписка</b>
|
||||
|
||||
Вы можете получить бесплатную тестовую подписку:
|
||||
|
||||
⏰ <b>Период:</b> {days} дней
|
||||
📈 <b>Трафик:</b> {traffic} ГБ
|
||||
📱 <b>Устройства:</b> {devices} шт.
|
||||
🌍 <b>Сервер:</b> {server_name}
|
||||
|
||||
Активировать тестовую подписку?
|
||||
"""
|
||||
|
||||
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
|
||||
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
|
||||
|
||||
CHANGE_DEVICES_TITLE = "📱 Изменение количества устройств"
|
||||
CHANGE_DEVICES_INFO = """
|
||||
📱 <b>Изменение количества устройств</b>
|
||||
|
||||
Текущий лимит: {current_devices} устройств
|
||||
|
||||
Выберите новое количество устройств:
|
||||
|
||||
💡 <b>Важно:</b>
|
||||
• При увеличении - доплата пропорционально оставшемуся времени
|
||||
• При уменьшении - возврат средств не производится
|
||||
"""
|
||||
|
||||
CHANGE_DEVICES_CONFIRM = """
|
||||
📱 <b>Подтверждение изменения</b>
|
||||
|
||||
Текущее количество: {current_devices} устройств
|
||||
Новое количество: {new_devices} устройств
|
||||
|
||||
Действие: {action}
|
||||
💰 {cost}
|
||||
|
||||
Подтвердить изменение?
|
||||
"""
|
||||
|
||||
CHANGE_DEVICES_SUCCESS_INCREASE = """
|
||||
✅ Количество устройств увеличено!
|
||||
|
||||
📱 Было: {old_count} → Стало: {new_count}
|
||||
💰 Списано: {amount}
|
||||
"""
|
||||
|
||||
CHANGE_DEVICES_SUCCESS_DECREASE = """
|
||||
✅ Количество устройств уменьшено!
|
||||
|
||||
📱 Было: {old_count} → Стало: {new_count}
|
||||
ℹ️ Возврат средств не производится
|
||||
"""
|
||||
|
||||
DEVICES_NO_CHANGE = "ℹ️ Количество устройств не изменилось"
|
||||
DEVICES_MINIMUM_LIMIT = "⚠️ Минимальное количество устройств: {limit}"
|
||||
DEVICES_LIMIT_EXCEEDED = "⚠️ Превышен максимальный лимит устройств ({limit})"
|
||||
DEVICES_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
|
||||
|
||||
BUY_SUBSCRIPTION_START = """
|
||||
💎 <b>Настройка подписки</b>
|
||||
|
||||
Давайте настроим вашу подписку под ваши потребности.
|
||||
|
||||
Сначала выберите период подписки:
|
||||
"""
|
||||
|
||||
SELECT_PERIOD = "Выберите период:"
|
||||
SELECT_TRAFFIC = "Выберите пакет трафика:"
|
||||
SELECT_COUNTRIES = "Выберите страны:"
|
||||
SELECT_DEVICES = "Количество устройств:"
|
||||
|
||||
PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"
|
||||
PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"
|
||||
PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"
|
||||
PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}"
|
||||
PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}"
|
||||
PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}"
|
||||
|
||||
TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"
|
||||
TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"
|
||||
TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"
|
||||
TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"
|
||||
TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"
|
||||
TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}"
|
||||
TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}"
|
||||
|
||||
SUBSCRIPTION_SUMMARY = """
|
||||
📋 <b>Итоговая конфигурация</b>
|
||||
|
||||
📅 <b>Период:</b> {period} дней
|
||||
📈 <b>Трафик:</b> {traffic}
|
||||
🌍 <b>Страны:</b> {countries}
|
||||
📱 <b>Устройства:</b> {devices}
|
||||
|
||||
💰 <b>Итого к оплате:</b> {total_price}
|
||||
|
||||
Подтвердить покупку?
|
||||
"""
|
||||
|
||||
INSUFFICIENT_BALANCE = """❌ Недостаточно средств на балансе.
|
||||
|
||||
<b>Пополните баланс на {amount} и попробуйте снова.</b>
|
||||
"""
|
||||
GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса"
|
||||
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
|
||||
|
||||
BALANCE_INFO = """
|
||||
💰 <b>Баланс: {balance}</b>
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
BALANCE_HISTORY = "📊 История операций"
|
||||
BALANCE_TOP_UP = "💳 Пополнить"
|
||||
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
|
||||
|
||||
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
|
||||
TOP_UP_METHODS = """
|
||||
💳 <b>Выберите способ оплаты</b>
|
||||
|
||||
Сумма: {amount}
|
||||
"""
|
||||
|
||||
TOP_UP_STARS = "⭐ Telegram Stars"
|
||||
TOP_UP_TRIBUTE = "💎 Банковская карта"
|
||||
|
||||
PROMOCODE_ENTER = "🎫 Введите промокод:"
|
||||
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
|
||||
PROMOCODE_INVALID = "❌ Неверный промокод"
|
||||
PROMOCODE_EXPIRED = "❌ Промокод истек"
|
||||
PROMOCODE_USED = "❌ Промокод уже использован"
|
||||
|
||||
REFERRAL_INFO = """
|
||||
🤝 <b>Реферальная программа</b>
|
||||
|
||||
👥 <b>Приглашено:</b> {referrals_count} друзей
|
||||
💰 <b>Заработано:</b> {earned_amount}
|
||||
|
||||
🔗 <b>Ваша реферальная ссылка:</b>
|
||||
<code>{referral_link}</code>
|
||||
|
||||
🎫 <b>Ваш промокод:</b>
|
||||
<code>{referral_code}</code>
|
||||
|
||||
💰 <b>Условия:</b>
|
||||
• За каждого друга: {registration_bonus}
|
||||
• Процент с пополнений: {commission_percent}%
|
||||
"""
|
||||
|
||||
REFERRAL_INVITE_MESSAGE = """
|
||||
🎯 <b>Приглашение в VPN сервис</b>
|
||||
|
||||
Привет! Приглашаю тебя в отличный VPN сервис!
|
||||
|
||||
🎁 По моей ссылке ты получишь бонус: {bonus}
|
||||
|
||||
🔗 Переходи: {link}
|
||||
🎫 Или используй промокод: {code}
|
||||
|
||||
💪 Быстро, надежно, недорого!
|
||||
"""
|
||||
|
||||
CREATE_INVITE = "📝 Создать приглашение"
|
||||
|
||||
TRIAL_ENDING_SOON = """
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через несколько часов.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {price}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Скорость до 1ГБит/сек
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_ACTIVE = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
|
||||
|
||||
⏰ Ориентировочное время завершения: неизвестно
|
||||
🔄 Попробуйте позже
|
||||
|
||||
Приносим извинения за временные неудобства.
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_API_ERROR = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен из-за проблем с подключением к серверам.
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {last_check}
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRING_PAID = """
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {end_date}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
|
||||
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
|
||||
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
|
||||
Новая дата окончания: {new_end_date}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
|
||||
💰 Ваш баланс: {balance}
|
||||
💳 Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
SUPPORT_INFO = f"""
|
||||
🛠️ <b>Техническая поддержка</b>
|
||||
|
||||
По всем вопросам обращайтесь к нашей поддержке:
|
||||
|
||||
👤 {settings.SUPPORT_USERNAME}
|
||||
|
||||
Мы поможем с:
|
||||
• Настройкой подключения
|
||||
• Решением технических проблем
|
||||
• Вопросами по оплате
|
||||
• Другими вопросами
|
||||
|
||||
⏰ Время ответа: обычно в течение 1-2 часов
|
||||
"""
|
||||
|
||||
CONTACT_SUPPORT = "💬 Написать в поддержку"
|
||||
|
||||
ADMIN_PANEL = """
|
||||
⚙️ <b>Административная панель</b>
|
||||
|
||||
Выберите раздел для управления:
|
||||
"""
|
||||
|
||||
ADMIN_USERS = "👥 Пользователи"
|
||||
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
|
||||
ADMIN_PROMOCODES = "🎫 Промокоды"
|
||||
ADMIN_MESSAGES = "📨 Рассылки"
|
||||
ADMIN_MONITORING = "🔍 Мониторинг"
|
||||
ADMIN_REFERRALS = "🤝 Партнерка"
|
||||
ADMIN_RULES = "📋 Правила"
|
||||
ADMIN_REMNAWAVE = "🖥️ Remnawave"
|
||||
ADMIN_STATISTICS = "📊 Статистика"
|
||||
|
||||
ACCESS_DENIED = "❌ Доступ запрещен"
|
||||
USER_NOT_FOUND = "❌ Пользователь не найден"
|
||||
SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена"
|
||||
INVALID_AMOUNT = "❌ Неверная сумма"
|
||||
OPERATION_CANCELLED = "❌ Операция отменена"
|
||||
|
||||
SUBSCRIPTION_EXPIRING = """
|
||||
⚠️ <b>Подписка истекает!</b>
|
||||
|
||||
Ваша подписка истекает через {days} дней.
|
||||
|
||||
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
"""
|
||||
|
||||
SWITCH_TRAFFIC_TITLE = "🔄 Переключение лимита трафика"
|
||||
SWITCH_TRAFFIC_INFO = """
|
||||
🔄 <b>Переключение лимита трафика</b>
|
||||
|
||||
Текущий лимит: {current_traffic}
|
||||
Выберите новый лимит трафика:
|
||||
|
||||
💡 <b>Важно:</b>
|
||||
• При увеличении - доплата за разницу пропорционально оставшемуся времени
|
||||
• При уменьшении - возврат средств не производится
|
||||
• Счетчик использованного трафика НЕ сбрасывается
|
||||
"""
|
||||
|
||||
SWITCH_TRAFFIC_CONFIRM = """
|
||||
🔄 <b>Подтверждение переключения трафика</b>
|
||||
|
||||
Текущий лимит: {current_traffic}
|
||||
Новый лимит: {new_traffic}
|
||||
|
||||
Действие: {action}
|
||||
💰 {cost}
|
||||
|
||||
Подтвердить переключение?
|
||||
"""
|
||||
|
||||
SWITCH_TRAFFIC_SUCCESS_INCREASE = """
|
||||
✅ Лимит трафика увеличен!
|
||||
|
||||
📊 Было: {old_traffic} → Стало: {new_traffic}
|
||||
💰 Списано: {amount}
|
||||
"""
|
||||
|
||||
SWITCH_TRAFFIC_SUCCESS_DECREASE = """
|
||||
✅ Лимит трафика уменьшен!
|
||||
|
||||
📊 Было: {old_traffic} → Стало: {new_traffic}
|
||||
ℹ️ Возврат средств не производится
|
||||
"""
|
||||
|
||||
TRAFFIC_NO_CHANGE = "ℹ️ Лимит трафика не изменился"
|
||||
TRAFFIC_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
Недостаточно средств на балансе: {balance}
|
||||
Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
|
||||
class EnglishTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("en")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Welcome to VPN Service!</b>
|
||||
|
||||
Our service provides fast and secure internet access without restrictions.
|
||||
|
||||
🔐 <b>Advantages:</b>
|
||||
• High connection speed
|
||||
• Servers in different countries
|
||||
• Reliable data protection
|
||||
• 24/7 support
|
||||
|
||||
To get started, select interface language:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
|
||||
|
||||
BACK = "⬅️ Back"
|
||||
CANCEL = "❌ Cancel"
|
||||
CONFIRM = "✅ Confirm"
|
||||
CONTINUE = "➡️ Continue"
|
||||
YES = "✅ Yes"
|
||||
NO = "❌ No"
|
||||
|
||||
MENU_BALANCE = "💰 Balance"
|
||||
MENU_SUBSCRIPTION = "📱 Subscription"
|
||||
MENU_TRIAL = "🎁 Trial subscription"
|
||||
INSUFFICIENT_BALANCE = """❌ Insufficient balance. " \
|
||||
|
||||
Top up {amount} and try again."""
|
||||
GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up"
|
||||
|
||||
|
||||
LANGUAGES = {
|
||||
"ru": RussianTexts,
|
||||
"en": EnglishTexts
|
||||
}
|
||||
|
||||
|
||||
def get_texts(language: str = "ru") -> Texts:
|
||||
return LANGUAGES.get(language, RussianTexts)()
|
||||
|
||||
async def get_rules_from_db(language: str = "ru") -> str:
|
||||
async def get_rules_from_db(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
|
||||
async for db in get_db():
|
||||
rules = await get_current_rules_content(db, language)
|
||||
if rules:
|
||||
_cached_rules[language] = rules
|
||||
return rules
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения правил из БД: {e}")
|
||||
|
||||
default_rules = _get_default_rules(language)
|
||||
_cached_rules[language] = default_rules
|
||||
return default_rules
|
||||
|
||||
def get_rules_sync(language: str = "ru") -> str:
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
_logger.warning("Failed to load rules from DB for %s: %s", language, error)
|
||||
|
||||
default = _get_default_rules(language)
|
||||
_cached_rules[language] = default
|
||||
return default
|
||||
|
||||
|
||||
def _get_default_rules(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
default_key = "RULES_TEXT_DEFAULT"
|
||||
locale = load_locale(language)
|
||||
if default_key in locale:
|
||||
return locale[default_key]
|
||||
fallback = load_locale(DEFAULT_LANGUAGE)
|
||||
return fallback.get(default_key, "")
|
||||
|
||||
|
||||
def get_rules_sync(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
try:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
rules = loop.run_until_complete(get_rules_from_db(language))
|
||||
loop.close()
|
||||
return rules
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения правил: {e}")
|
||||
return _get_default_rules(language)
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(get_rules(language))
|
||||
|
||||
async def refresh_rules_cache(language: str = "ru"):
|
||||
try:
|
||||
if language in _cached_rules:
|
||||
del _cached_rules[language]
|
||||
|
||||
await get_rules_from_db(language)
|
||||
print(f"✅ Кеш правил для языка {language} обновлен")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка обновления кеша правил: {e}")
|
||||
loop.create_task(get_rules(language))
|
||||
return _get_cached_rules_value(language)
|
||||
|
||||
def clear_rules_cache():
|
||||
global _cached_rules
|
||||
|
||||
async def get_rules(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
return await get_rules_from_db(language)
|
||||
|
||||
|
||||
async def refresh_rules_cache(language: str = DEFAULT_LANGUAGE) -> None:
|
||||
if language in _cached_rules:
|
||||
del _cached_rules[language]
|
||||
await get_rules_from_db(language)
|
||||
|
||||
|
||||
def clear_rules_cache() -> None:
|
||||
_cached_rules.clear()
|
||||
print("✅ Кеш правил очищен")
|
||||
|
||||
|
||||
def reload_locales() -> None:
|
||||
clear_locale_cache()
|
||||
|
||||
@@ -8,6 +8,8 @@ from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from app.config import settings
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -111,9 +113,27 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
@staticmethod
|
||||
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
|
||||
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
|
||||
channel_sub_kb = get_channel_sub_keyboard(channel_link)
|
||||
text = f"""🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!"""
|
||||
|
||||
|
||||
user = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = getattr(event, "from_user", None)
|
||||
elif isinstance(event, Update):
|
||||
if event.message and event.message.from_user:
|
||||
user = event.message.from_user
|
||||
elif event.callback_query and event.callback_query.from_user:
|
||||
user = event.callback_query.from_user
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
if user and user.language_code:
|
||||
language = user.language_code.split('-')[0]
|
||||
|
||||
texts = get_texts(language)
|
||||
channel_sub_kb = get_channel_sub_keyboard(channel_link, language=language)
|
||||
text = texts.t(
|
||||
"CHANNEL_REQUIRED_TEXT",
|
||||
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
return await event.answer(text, reply_markup=channel_sub_kb)
|
||||
|
||||
@@ -4,6 +4,7 @@ import time
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,13 +34,31 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
if now - last_call < self.rate_limit:
|
||||
logger.warning(f"🚫 Throttling для пользователя {user_id}")
|
||||
|
||||
|
||||
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
|
||||
if isinstance(event, Message):
|
||||
try:
|
||||
fsm: FSMContext = data.get("state") # может отсутствовать
|
||||
current = await fsm.get_state() if fsm else None
|
||||
except Exception:
|
||||
current = None
|
||||
is_ticket_state = False
|
||||
if current:
|
||||
# Молчим только в состояниях работы с тикетами (user/admin): waiting_for_message / waiting_for_reply
|
||||
lowered = str(current)
|
||||
is_ticket_state = (
|
||||
(":waiting_for_message" in lowered or ":waiting_for_reply" in lowered) and
|
||||
("TicketStates" in lowered or "AdminTicketStates" in lowered)
|
||||
)
|
||||
if is_ticket_state:
|
||||
return
|
||||
# В остальных случаях — явный блок
|
||||
await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!")
|
||||
return
|
||||
# Для callback допустим краткое уведомление
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True)
|
||||
|
||||
return
|
||||
return
|
||||
|
||||
self.user_buckets[user_id] = now
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram import Bot, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -18,6 +18,7 @@ class AdminNotificationService:
|
||||
self.bot = bot
|
||||
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
|
||||
@@ -294,7 +295,7 @@ class AdminNotificationService:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
return False
|
||||
|
||||
async def _send_message(self, text: str) -> bool:
|
||||
async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
|
||||
return False
|
||||
@@ -307,8 +308,16 @@ class AdminNotificationService:
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
|
||||
if self.topic_id:
|
||||
message_kwargs['message_thread_id'] = self.topic_id
|
||||
# route to ticket-specific topic if provided
|
||||
thread_id = None
|
||||
if ticket_event and self.ticket_topic_id:
|
||||
thread_id = self.ticket_topic_id
|
||||
elif self.topic_id:
|
||||
thread_id = self.topic_id
|
||||
if thread_id:
|
||||
message_kwargs['message_thread_id'] = thread_id
|
||||
if reply_markup is not None:
|
||||
message_kwargs['reply_markup'] = reply_markup
|
||||
|
||||
await self.bot.send_message(**message_kwargs)
|
||||
logger.info(f"Уведомление отправлено в чат {self.chat_id}")
|
||||
@@ -801,3 +810,15 @@ class AdminNotificationService:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
async def send_ticket_event_notification(
|
||||
self,
|
||||
text: str,
|
||||
keyboard: types.InlineKeyboardMarkup | None = None
|
||||
) -> bool:
|
||||
"""Публичный метод для отправки уведомлений по тикетам в админ-топик.
|
||||
Учитывает настройки включенности в settings.
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import record_campaign_registration
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
get_subscription_by_user_id,
|
||||
)
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.models import AdvertisingCampaign, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CampaignBonusResult:
|
||||
success: bool
|
||||
bonus_type: Optional[str] = None
|
||||
balance_kopeks: int = 0
|
||||
subscription_days: Optional[int] = None
|
||||
subscription_traffic_gb: Optional[int] = None
|
||||
subscription_device_limit: Optional[int] = None
|
||||
subscription_squads: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AdvertisingCampaignService:
|
||||
def __init__(self) -> None:
|
||||
self.subscription_service = SubscriptionService()
|
||||
|
||||
async def apply_campaign_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign: AdvertisingCampaign,
|
||||
) -> CampaignBonusResult:
|
||||
if not campaign.is_active:
|
||||
logger.warning(
|
||||
"⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id
|
||||
)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
if campaign.is_balance_bonus:
|
||||
return await self._apply_balance_bonus(db, user, campaign)
|
||||
|
||||
if campaign.is_subscription_bonus:
|
||||
return await self._apply_subscription_bonus(db, user, campaign)
|
||||
|
||||
logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
async def _apply_balance_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign: AdvertisingCampaign,
|
||||
) -> CampaignBonusResult:
|
||||
amount = campaign.balance_bonus_kopeks or 0
|
||||
if amount <= 0:
|
||||
logger.info("ℹ️ Кампания %s не имеет бонуса на баланс", campaign.id)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
description = f"Бонус за регистрацию по кампании '{campaign.name}'"
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount,
|
||||
description=description,
|
||||
)
|
||||
|
||||
if not success:
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
await record_campaign_registration(
|
||||
db,
|
||||
campaign_id=campaign.id,
|
||||
user_id=user.id,
|
||||
bonus_type="balance",
|
||||
balance_bonus_kopeks=amount,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"💰 Пользователю %s начислен бонус %s₽ по кампании %s",
|
||||
user.telegram_id,
|
||||
amount / 100,
|
||||
campaign.id,
|
||||
)
|
||||
|
||||
return CampaignBonusResult(
|
||||
success=True,
|
||||
bonus_type="balance",
|
||||
balance_kopeks=amount,
|
||||
)
|
||||
|
||||
async def _apply_subscription_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign: AdvertisingCampaign,
|
||||
) -> CampaignBonusResult:
|
||||
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if existing_subscription:
|
||||
logger.warning(
|
||||
"⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен",
|
||||
user.telegram_id,
|
||||
campaign.id,
|
||||
)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
duration_days = campaign.subscription_duration_days or 0
|
||||
if duration_days <= 0:
|
||||
logger.info(
|
||||
"ℹ️ Кампания %s не содержит корректной длительности подписки",
|
||||
campaign.id,
|
||||
)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
traffic_limit = campaign.subscription_traffic_gb
|
||||
device_limit = (
|
||||
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
)
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=duration_days,
|
||||
traffic_limit_gb=traffic_limit or 0,
|
||||
device_limit=device_limit,
|
||||
connected_squads=squads,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.subscription_service.create_remnawave_user(db, new_subscription)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"❌ Ошибка синхронизации RemnaWave для кампании %s: %s",
|
||||
campaign.id,
|
||||
error,
|
||||
)
|
||||
|
||||
await record_campaign_registration(
|
||||
db,
|
||||
campaign_id=campaign.id,
|
||||
user_id=user.id,
|
||||
bonus_type="subscription",
|
||||
subscription_duration_days=duration_days,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"🎁 Пользователю %s выдана подписка по кампании %s на %s дней",
|
||||
user.telegram_id,
|
||||
campaign.id,
|
||||
duration_days,
|
||||
)
|
||||
|
||||
return CampaignBonusResult(
|
||||
success=True,
|
||||
bonus_type="subscription",
|
||||
subscription_days=duration_days,
|
||||
subscription_traffic_gb=traffic_limit or 0,
|
||||
subscription_device_limit=device_limit,
|
||||
subscription_squads=squads,
|
||||
)
|
||||
+51
-109
@@ -22,6 +22,10 @@ from app.external.cryptobot import CryptoBotService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.database.database import get_db
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,7 +37,49 @@ class PaymentService:
|
||||
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
|
||||
self.stars_service = TelegramStarsService(bot) if bot else None
|
||||
self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None
|
||||
|
||||
|
||||
async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language if user else "ru")
|
||||
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
|
||||
|
||||
if user:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
])
|
||||
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")
|
||||
])
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
async def create_stars_invoice(
|
||||
self,
|
||||
amount_kopeks: int,
|
||||
@@ -124,33 +170,7 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
@@ -432,33 +452,7 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
@@ -545,33 +539,7 @@ class PaymentService:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
break
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
message = (
|
||||
f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
@@ -827,33 +795,7 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerStatusEntry:
|
||||
address: str
|
||||
instance: Optional[str]
|
||||
protocol: Optional[str]
|
||||
name: str
|
||||
flag: str
|
||||
display_name: str
|
||||
latency_ms: Optional[int]
|
||||
is_online: bool
|
||||
|
||||
|
||||
class ServerStatusError(Exception):
|
||||
"""Raised when server status information cannot be fetched or parsed."""
|
||||
|
||||
|
||||
class ServerStatusService:
|
||||
_LATENCY_PATTERN = re.compile(
|
||||
r"xray_proxy_latency_ms\{(?P<labels>[^}]*)\}\s+(?P<value>[-+]?\d+(?:\.\d+)?)"
|
||||
)
|
||||
_STATUS_PATTERN = re.compile(
|
||||
r"xray_proxy_status\{(?P<labels>[^}]*)\}\s+(?P<value>[-+]?\d+(?:\.\d+)?)"
|
||||
)
|
||||
_LABEL_PATTERN = re.compile(r"(?P<key>[a-zA-Z_][a-zA-Z0-9_]*)=\"(?P<value>(?:\\.|[^\"])*)\"")
|
||||
_FLAG_PATTERN = re.compile(r"^([\U0001F1E6-\U0001F1FF]{2})\s*(.*)$")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
async def get_servers(self) -> List[ServerStatusEntry]:
|
||||
mode = settings.get_server_status_mode()
|
||||
if mode != "xray":
|
||||
raise ServerStatusError("Server status integration is not enabled")
|
||||
|
||||
url = settings.get_server_status_metrics_url()
|
||||
if not url:
|
||||
raise ServerStatusError("Metrics URL is not configured")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=settings.get_server_status_request_timeout())
|
||||
auth = None
|
||||
auth_credentials = settings.get_server_status_metrics_auth()
|
||||
if auth_credentials:
|
||||
username, password = auth_credentials
|
||||
auth = aiohttp.BasicAuth(username, password)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(
|
||||
url,
|
||||
auth=auth,
|
||||
ssl=settings.SERVER_STATUS_METRICS_VERIFY_SSL,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
text = await response.text()
|
||||
raise ServerStatusError(
|
||||
f"Unexpected response status: {response.status}"
|
||||
f" - {text[:200]}"
|
||||
)
|
||||
metrics_body = await response.text()
|
||||
except asyncio.TimeoutError as error:
|
||||
raise ServerStatusError("Request to metrics endpoint timed out") from error
|
||||
except aiohttp.ClientError as error:
|
||||
raise ServerStatusError("Failed to fetch metrics") from error
|
||||
|
||||
return self._parse_metrics(metrics_body)
|
||||
|
||||
def _parse_metrics(self, body: str) -> List[ServerStatusEntry]:
|
||||
servers: Dict[Tuple[str, str, str, str], ServerStatusEntry] = {}
|
||||
|
||||
for match in self._LATENCY_PATTERN.finditer(body):
|
||||
labels = self._parse_labels(match.group("labels"))
|
||||
key = self._build_key(labels)
|
||||
entry = servers.get(key)
|
||||
if not entry:
|
||||
entry = self._create_entry(labels)
|
||||
servers[key] = entry
|
||||
|
||||
try:
|
||||
value = float(match.group("value"))
|
||||
entry.latency_ms = int(round(value))
|
||||
except (TypeError, ValueError):
|
||||
entry.latency_ms = None
|
||||
|
||||
for match in self._STATUS_PATTERN.finditer(body):
|
||||
labels = self._parse_labels(match.group("labels"))
|
||||
key = self._build_key(labels)
|
||||
entry = servers.get(key)
|
||||
if not entry:
|
||||
entry = self._create_entry(labels)
|
||||
servers[key] = entry
|
||||
|
||||
try:
|
||||
value = float(match.group("value"))
|
||||
entry.is_online = value >= 1
|
||||
except (TypeError, ValueError):
|
||||
entry.is_online = False
|
||||
|
||||
return sorted(
|
||||
servers.values(),
|
||||
key=lambda item: (
|
||||
0 if item.is_online else 1,
|
||||
(item.display_name or item.name).lower(),
|
||||
),
|
||||
)
|
||||
|
||||
def _build_key(self, labels: Dict[str, str]) -> Tuple[str, str, str, str]:
|
||||
return (
|
||||
labels.get("address", ""),
|
||||
labels.get("instance", ""),
|
||||
labels.get("protocol", ""),
|
||||
labels.get("name", labels.get("address", "")),
|
||||
)
|
||||
|
||||
def _create_entry(self, labels: Dict[str, str]) -> ServerStatusEntry:
|
||||
name = labels.get("name") or labels.get("address") or "Unknown"
|
||||
flag, display_name = self._extract_flag(name)
|
||||
return ServerStatusEntry(
|
||||
address=labels.get("address", ""),
|
||||
instance=labels.get("instance"),
|
||||
protocol=labels.get("protocol"),
|
||||
name=name,
|
||||
flag=flag,
|
||||
display_name=display_name or name,
|
||||
latency_ms=None,
|
||||
is_online=False,
|
||||
)
|
||||
|
||||
def _extract_flag(self, name: str) -> Tuple[str, str]:
|
||||
match = self._FLAG_PATTERN.match(name)
|
||||
if not match:
|
||||
return "", name
|
||||
flag, remainder = match.groups()
|
||||
return flag, remainder.strip()
|
||||
|
||||
def _parse_labels(self, labels_str: str) -> Dict[str, str]:
|
||||
labels: Dict[str, str] = {}
|
||||
for match in self._LABEL_PATTERN.finditer(labels_str):
|
||||
key = match.group("key")
|
||||
value = match.group("value").replace('\\"', '"')
|
||||
labels[key] = value
|
||||
return labels
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.database.models import User
|
||||
from app.utils.cache import UserCache
|
||||
|
||||
|
||||
_CHECKOUT_SESSION_KEY = "subscription_checkout"
|
||||
_CHECKOUT_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
async def save_subscription_checkout_draft(
|
||||
user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS
|
||||
) -> bool:
|
||||
"""Persist subscription checkout draft data in cache."""
|
||||
|
||||
return await UserCache.set_user_session(user_id, _CHECKOUT_SESSION_KEY, data, ttl)
|
||||
|
||||
|
||||
async def get_subscription_checkout_draft(user_id: int) -> Optional[dict]:
|
||||
"""Retrieve subscription checkout draft from cache."""
|
||||
|
||||
return await UserCache.get_user_session(user_id, _CHECKOUT_SESSION_KEY)
|
||||
|
||||
|
||||
async def clear_subscription_checkout_draft(user_id: int) -> bool:
|
||||
"""Remove stored subscription checkout draft for the user."""
|
||||
|
||||
return await UserCache.delete_user_session(user_id, _CHECKOUT_SESSION_KEY)
|
||||
|
||||
|
||||
async def has_subscription_checkout_draft(user_id: int) -> bool:
|
||||
draft = await get_subscription_checkout_draft(user_id)
|
||||
return draft is not None
|
||||
|
||||
|
||||
def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
|
||||
"""
|
||||
Determine whether checkout resume button should be available for the user.
|
||||
|
||||
Only users without an active paid subscription or users currently on trial
|
||||
are eligible to continue assembling the subscription from the saved draft.
|
||||
"""
|
||||
|
||||
if not has_draft:
|
||||
return False
|
||||
|
||||
subscription = getattr(user, "subscription", None)
|
||||
|
||||
if subscription is None:
|
||||
return True
|
||||
|
||||
return bool(getattr(subscription, "is_trial", False))
|
||||
@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, SubscriptionStatus
|
||||
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
@@ -19,6 +19,25 @@ from app.utils.pricing_utils import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
def get_traffic_reset_strategy():
|
||||
from app.config import settings
|
||||
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
|
||||
@@ -266,9 +285,12 @@ class SubscriptionService:
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
@@ -279,68 +301,167 @@ class SubscriptionService:
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_prices.append(server.price_kopeks)
|
||||
total_servers_price += server.price_kopeks
|
||||
logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}₽")
|
||||
server_price = server.price_kopeks
|
||||
server_discount = server_price * servers_discount_percent // 100
|
||||
discounted_server_price = server_price - server_discount
|
||||
server_prices.append(discounted_server_price)
|
||||
total_servers_price += discounted_server_price
|
||||
log_message = f"Сервер {server.display_name}: {server_price/100}₽"
|
||||
if server_discount > 0:
|
||||
log_message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount/100}₽ → {discounted_server_price/100}₽)"
|
||||
)
|
||||
logger.debug(log_message)
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_price = base_price + traffic_price + total_servers_price + devices_price
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price * devices_discount_percent // 100
|
||||
discounted_devices_price = devices_price - devices_discount
|
||||
|
||||
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки:")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
if traffic_price > 0:
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽")
|
||||
if discounted_traffic_price > 0:
|
||||
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
if devices_price > 0:
|
||||
logger.info(f" Устройства ({devices}): {devices_price/100}₽")
|
||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||
if servers_discount_percent > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||
)
|
||||
logger.info(message)
|
||||
if discounted_devices_price > 0:
|
||||
message = f" Устройства ({devices}): {devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount = servers_price * servers_discount_percent // 100
|
||||
discounted_servers_price = servers_price - servers_discount
|
||||
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price * devices_discount_percent // 100
|
||||
discounted_devices_price = devices_price - devices_discount
|
||||
|
||||
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
total_price = base_price + servers_price + devices_price + traffic_price
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
total_price = (
|
||||
base_price
|
||||
+ discounted_servers_price
|
||||
+ discounted_devices_price
|
||||
+ discounted_traffic_price
|
||||
)
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
if servers_price > 0:
|
||||
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}₽")
|
||||
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽"
|
||||
if servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if devices_price > 0:
|
||||
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}₽")
|
||||
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if traffic_price > 0:
|
||||
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}₽")
|
||||
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
@@ -440,9 +561,12 @@ class SubscriptionService:
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
@@ -455,89 +579,183 @@ class SubscriptionService:
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_price_total = server_price_per_month * months_in_period
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_price_total = discounted_server_per_month * months_in_period
|
||||
server_prices.append(server_price_total)
|
||||
total_servers_price += server_price_total
|
||||
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽")
|
||||
log_message = (
|
||||
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
log_message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.debug(log_message)
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||
if servers_discount_percent > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price_with_months(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
if total_servers_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
|
||||
)
|
||||
if servers_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
@@ -556,32 +774,92 @@ class SubscriptionService:
|
||||
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = user.promo_group if user else None
|
||||
|
||||
total_price = 0
|
||||
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
total_price += traffic_price_per_month * months_to_pay
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay} = {traffic_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_price = discounted_traffic_per_month * months_to_pay
|
||||
total_price += traffic_total_price
|
||||
message = (
|
||||
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay}"
|
||||
f" = {traffic_total_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%:"
|
||||
f" -{traffic_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_price += devices_price_per_month * months_to_pay
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay} = {devices_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_price = discounted_devices_per_month * months_to_pay
|
||||
total_price += devices_total_price
|
||||
message = (
|
||||
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay}"
|
||||
f" = {devices_total_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%:"
|
||||
f" -{devices_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids and db:
|
||||
for server_id in additional_server_ids:
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_total_price = server_price_per_month * months_to_pay
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = (
|
||||
server_price_per_month * servers_discount_percent // 100
|
||||
)
|
||||
discounted_server_per_month = (
|
||||
server_price_per_month - server_discount_per_month
|
||||
)
|
||||
server_total_price = discounted_server_per_month * months_to_pay
|
||||
total_price += server_total_price
|
||||
logger.info(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay} = {server_total_price/100}₽")
|
||||
|
||||
message = (
|
||||
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay}"
|
||||
f" = {server_total_price/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{server_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}₽")
|
||||
return total_price
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SupportSettingsService:
|
||||
"""Runtime editable support settings with JSON persistence."""
|
||||
|
||||
_storage_path: Path = Path("data/support_settings.json")
|
||||
_data: Dict = {}
|
||||
_loaded: bool = False
|
||||
|
||||
@classmethod
|
||||
def _ensure_dir(cls) -> None:
|
||||
try:
|
||||
cls._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure settings dir: {e}")
|
||||
|
||||
@classmethod
|
||||
def _load(cls) -> None:
|
||||
if cls._loaded:
|
||||
return
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
if cls._storage_path.exists():
|
||||
cls._data = json.loads(cls._storage_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
cls._data = {}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load support settings: {e}")
|
||||
cls._data = {}
|
||||
cls._loaded = True
|
||||
|
||||
@classmethod
|
||||
def _save(cls) -> bool:
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
cls._storage_path.write_text(json.dumps(cls._data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save support settings: {e}")
|
||||
return False
|
||||
|
||||
# Mode
|
||||
@classmethod
|
||||
def get_system_mode(cls) -> str:
|
||||
cls._load()
|
||||
mode = (cls._data.get("system_mode") or settings.get_support_system_mode()).strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
@classmethod
|
||||
def set_system_mode(cls, mode: str) -> bool:
|
||||
mode_clean = (mode or "").strip().lower()
|
||||
if mode_clean not in {"tickets", "contact", "both"}:
|
||||
return False
|
||||
cls._load()
|
||||
cls._data["system_mode"] = mode_clean
|
||||
return cls._save()
|
||||
|
||||
# Main menu visibility
|
||||
@classmethod
|
||||
def is_support_menu_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "menu_enabled" in cls._data:
|
||||
return bool(cls._data["menu_enabled"])
|
||||
return bool(settings.SUPPORT_MENU_ENABLED)
|
||||
|
||||
@classmethod
|
||||
def set_support_menu_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["menu_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
# Contact vs tickets helpers
|
||||
@classmethod
|
||||
def is_tickets_enabled(cls) -> bool:
|
||||
return cls.get_system_mode() in {"tickets", "both"}
|
||||
|
||||
@classmethod
|
||||
def is_contact_enabled(cls) -> bool:
|
||||
return cls.get_system_mode() in {"contact", "both"}
|
||||
|
||||
# Descriptions (per language)
|
||||
@classmethod
|
||||
def get_support_info_text(cls, language: str) -> str:
|
||||
cls._load()
|
||||
lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
overrides = cls._data.get("support_info_texts") or {}
|
||||
text = overrides.get(lang)
|
||||
if text and isinstance(text, str) and text.strip():
|
||||
return text
|
||||
# Fallback to dynamic localization default
|
||||
from app.localization.texts import get_texts
|
||||
return get_texts(lang).SUPPORT_INFO
|
||||
|
||||
@classmethod
|
||||
def set_support_info_text(cls, language: str, text: str) -> bool:
|
||||
cls._load()
|
||||
lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
texts_map = cls._data.get("support_info_texts") or {}
|
||||
texts_map[lang] = text or ""
|
||||
cls._data["support_info_texts"] = texts_map
|
||||
return cls._save()
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ from datetime import datetime
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.models import Transaction, TransactionType, PaymentMethod
|
||||
@@ -15,7 +13,7 @@ from app.database.crud.transaction import (
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -216,7 +214,7 @@ class TributeService:
|
||||
logger.error(f"Ошибка обработки возврата Tribute: {e}")
|
||||
|
||||
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
|
||||
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
@@ -224,34 +222,8 @@ class TributeService:
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
break
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
]
|
||||
)
|
||||
payment_service = PaymentService(self.bot)
|
||||
keyboard = await payment_service.build_topup_success_keyboard(user)
|
||||
|
||||
text = (
|
||||
f"✅ **Платеж успешно получен!**\n\n"
|
||||
@@ -267,10 +239,11 @@ class TributeService:
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
|
||||
|
||||
|
||||
|
||||
async def _send_failure_notification(self, user_id: int):
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, update
|
||||
from aiogram import Bot
|
||||
@@ -10,13 +10,14 @@ from app.database.crud.user import (
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -145,13 +146,14 @@ class UserService:
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
status: Optional[UserStatus] = None
|
||||
status: Optional[UserStatus] = None,
|
||||
order_by_balance: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
users = await get_users_list(
|
||||
db, offset=offset, limit=limit, status=status
|
||||
db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance
|
||||
)
|
||||
total_count = await get_users_count(db, status=status)
|
||||
|
||||
@@ -191,10 +193,10 @@ class UserService:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
|
||||
# Сохраняем старый баланс для уведомления
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
|
||||
if amount_kopeks > 0:
|
||||
await add_user_balance(db, user, amount_kopeks, description=description)
|
||||
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
|
||||
@@ -203,26 +205,61 @@ class UserService:
|
||||
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
|
||||
if success:
|
||||
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽")
|
||||
|
||||
|
||||
# Отправляем уведомление пользователю, если операция прошла успешно
|
||||
if success and bot:
|
||||
# Обновляем пользователя для получения нового баланса
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
# Получаем имя администратора
|
||||
if not admin_name:
|
||||
admin_user = await get_user_by_id(db, admin_id)
|
||||
admin_name = admin_user.full_name if admin_user else f"Админ #{admin_id}"
|
||||
|
||||
|
||||
# Отправляем уведомление (не блокируем операцию если не удалось отправить)
|
||||
await self._send_balance_notification(bot, user, amount_kopeks, admin_name)
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def update_user_promo_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> Tuple[bool, Optional[User], Optional[PromoGroup]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False, None, None
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, promo_group_id)
|
||||
if not promo_group:
|
||||
return False, None, None
|
||||
|
||||
user.promo_group_id = promo_group.id
|
||||
user.promo_group = promo_group
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"👥 Промогруппа пользователя %s обновлена на '%s'",
|
||||
user.telegram_id,
|
||||
promo_group.name,
|
||||
)
|
||||
|
||||
return True, user, promo_group
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
|
||||
return False, None, None
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
+44
-1
@@ -16,6 +16,7 @@ class SubscriptionStates(StatesGroup):
|
||||
adding_devices = State()
|
||||
extending_subscription = State()
|
||||
confirming_traffic_reset = State()
|
||||
cart_saved_for_topup = State()
|
||||
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
@@ -41,10 +42,37 @@ class AdminStates(StatesGroup):
|
||||
setting_promocode_value = State()
|
||||
setting_promocode_uses = State()
|
||||
setting_promocode_expiry = State()
|
||||
|
||||
|
||||
creating_campaign_name = State()
|
||||
creating_campaign_start = State()
|
||||
creating_campaign_bonus = State()
|
||||
creating_campaign_balance = State()
|
||||
creating_campaign_subscription_days = State()
|
||||
creating_campaign_subscription_traffic = State()
|
||||
creating_campaign_subscription_devices = State()
|
||||
creating_campaign_subscription_servers = State()
|
||||
|
||||
editing_campaign_name = State()
|
||||
editing_campaign_start = State()
|
||||
editing_campaign_balance = State()
|
||||
editing_campaign_subscription_days = State()
|
||||
editing_campaign_subscription_traffic = State()
|
||||
editing_campaign_subscription_devices = State()
|
||||
editing_campaign_subscription_servers = State()
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
waiting_for_broadcast_media = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
creating_promo_group_name = State()
|
||||
creating_promo_group_traffic_discount = State()
|
||||
creating_promo_group_server_discount = State()
|
||||
creating_promo_group_device_discount = State()
|
||||
|
||||
editing_promo_group_name = State()
|
||||
editing_promo_group_traffic_discount = State()
|
||||
editing_promo_group_server_discount = State()
|
||||
editing_promo_group_device_discount = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
@@ -69,10 +97,25 @@ class AdminStates(StatesGroup):
|
||||
|
||||
editing_welcome_text = State()
|
||||
waiting_for_message_buttons = "waiting_for_message_buttons"
|
||||
|
||||
# Состояния для отслеживания источника перехода
|
||||
viewing_user_from_balance_list = State()
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
class TicketStates(StatesGroup):
|
||||
waiting_for_title = State()
|
||||
waiting_for_message = State()
|
||||
waiting_for_reply = State()
|
||||
|
||||
class AdminTicketStates(StatesGroup):
|
||||
waiting_for_reply = State()
|
||||
waiting_for_block_duration = State()
|
||||
|
||||
class SupportSettingsStates(StatesGroup):
|
||||
waiting_for_desc = State()
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
confirming_autopay_toggle = State()
|
||||
|
||||
+8
-3
@@ -203,14 +203,19 @@ class UserCache:
|
||||
|
||||
@staticmethod
|
||||
async def set_user_session(
|
||||
user_id: int,
|
||||
session_key: str,
|
||||
data: Any,
|
||||
user_id: int,
|
||||
session_key: str,
|
||||
data: Any,
|
||||
expire: int = 1800
|
||||
) -> bool:
|
||||
key = cache_key("session", user_id, session_key)
|
||||
return await cache.set(key, data, expire)
|
||||
|
||||
@staticmethod
|
||||
async def delete_user_session(user_id: int, session_key: str) -> bool:
|
||||
key = cache_key("session", user_id, session_key)
|
||||
return await cache.delete(key)
|
||||
|
||||
|
||||
class SystemCache:
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from aiogram.types import Message, FSInputFile, InputMediaPhoto
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.config import settings
|
||||
|
||||
@@ -15,13 +16,39 @@ _original_edit_text = Message.edit_text
|
||||
|
||||
|
||||
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
# Если caption слишком длинный для фото — отправим как текст
|
||||
try:
|
||||
if text is not None and len(text) > 900:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
if LOGO_PATH.exists():
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
try:
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
except Exception:
|
||||
# Фоллбек, если Telegram ругается на caption: отправим как текст
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
|
||||
|
||||
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:
|
||||
# Если caption потенциально слишком длинный — отправим как текст вместо caption
|
||||
try:
|
||||
if text is not None and len(text) > 900:
|
||||
try:
|
||||
await self.delete()
|
||||
except Exception:
|
||||
pass
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self):
|
||||
@@ -32,8 +59,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
media = self.photo[-1].file_id
|
||||
media_kwargs = {"media": media, "caption": text}
|
||||
if "parse_mode" in kwargs:
|
||||
media_kwargs["parse_mode"] = kwargs.pop("parse_mode")
|
||||
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
|
||||
_pm = 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:
|
||||
# Фоллбек: удалим и отправим обычный текст без фото
|
||||
try:
|
||||
await self.delete()
|
||||
except Exception:
|
||||
pass
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
|
||||
|
||||
|
||||
+36
-12
@@ -1,5 +1,7 @@
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
"""
|
||||
@@ -54,28 +56,50 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
|
||||
return methods
|
||||
|
||||
def get_payment_methods_text() -> str:
|
||||
def get_payment_methods_text(language: str) -> str:
|
||||
"""
|
||||
Генерирует текст с описанием доступных способов оплаты
|
||||
"""
|
||||
texts = get_texts(language)
|
||||
methods = get_available_payment_methods()
|
||||
|
||||
|
||||
if len(methods) <= 1: # Только поддержка
|
||||
return """💳 <b>Способы пополнения баланса</b>
|
||||
return texts.t(
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT",
|
||||
"""💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
⚠️ В данный момент автоматические способы оплаты временно недоступны.
|
||||
Обратитесь в техподдержку для пополнения баланса.
|
||||
|
||||
Выберите способ пополнения:"""
|
||||
|
||||
text = "💳 <b>Способы пополнения баланса</b>\n\n"
|
||||
text += "Выберите удобный для вас способ оплаты:\n\n"
|
||||
|
||||
Выберите способ пополнения:""",
|
||||
)
|
||||
|
||||
text = texts.t(
|
||||
"PAYMENT_METHODS_TITLE",
|
||||
"💳 <b>Способы пополнения баланса</b>",
|
||||
) + "\n\n"
|
||||
text += texts.t(
|
||||
"PAYMENT_METHODS_PROMPT",
|
||||
"Выберите удобный для вас способ оплаты:",
|
||||
) + "\n\n"
|
||||
|
||||
for method in methods:
|
||||
text += f"{method['icon']} <b>{method['name']}</b> - {method['description']}\n"
|
||||
|
||||
text += "\nВыберите способ пополнения:"
|
||||
|
||||
method_id = method['id'].upper()
|
||||
name = texts.t(
|
||||
f"PAYMENT_METHOD_{method_id}_NAME",
|
||||
f"{method['icon']} <b>{method['name']}</b>",
|
||||
)
|
||||
description = texts.t(
|
||||
f"PAYMENT_METHOD_{method_id}_DESCRIPTION",
|
||||
method['description'],
|
||||
)
|
||||
text += f"{name} - {description}\n"
|
||||
|
||||
text += "\n" + texts.t(
|
||||
"PAYMENT_METHODS_FOOTER",
|
||||
"Выберите способ пополнения:",
|
||||
)
|
||||
|
||||
return text
|
||||
|
||||
def is_payment_method_available(method_id: str) -> bool:
|
||||
|
||||
@@ -23,6 +23,7 @@ async def edit_or_answer_photo(
|
||||
keyboard: types.InlineKeyboardMarkup,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> None:
|
||||
# Если режим логотипа выключен — работаем текстом
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
@@ -47,17 +48,44 @@ async def edit_or_answer_photo(
|
||||
)
|
||||
return
|
||||
|
||||
# Если текст слишком длинный для caption — отправим как текст
|
||||
if caption and len(caption) > 1000:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
return
|
||||
|
||||
media = _resolve_media(callback.message)
|
||||
try:
|
||||
await callback.message.edit_media(
|
||||
InputMediaPhoto(media=media, caption=caption, parse_mode=parse_mode),
|
||||
InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or "HTML")),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
FSInputFile(LOGO_PATH),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
# Фоллбек: если не удалось обновить фото — отправим текст, чтобы не упасть на лимите caption
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# Отправим как фото с логотипом
|
||||
await callback.message.answer_photo(
|
||||
photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=(parse_mode or "HTML"),
|
||||
)
|
||||
except Exception:
|
||||
# Последний фоллбек — обычный текст
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=(parse_mode or "HTML"),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
import logging
|
||||
|
||||
@@ -28,8 +29,8 @@ def calculate_period_multiplier(period_days: int) -> Tuple[int, float]:
|
||||
|
||||
|
||||
def calculate_prorated_price(
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
min_charge_months: int = 1
|
||||
) -> Tuple[int, int]:
|
||||
months_remaining = get_remaining_months(end_date)
|
||||
@@ -42,10 +43,31 @@ def calculate_prorated_price(
|
||||
return total_price, months_to_charge
|
||||
|
||||
|
||||
def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
|
||||
if amount <= 0 or percent <= 0:
|
||||
return amount, 0
|
||||
|
||||
clamped_percent = max(0, min(100, percent))
|
||||
discount_value = amount * clamped_percent // 100
|
||||
discounted_amount = amount - discount_value
|
||||
|
||||
logger.debug(
|
||||
"Применена скидка %s%%: %s → %s (скидка %s)",
|
||||
clamped_percent,
|
||||
amount,
|
||||
discounted_amount,
|
||||
discount_value,
|
||||
)
|
||||
|
||||
return discounted_amount, discount_value
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
if language == "ru":
|
||||
if days == 14:
|
||||
return "14 дней"
|
||||
if days == 30:
|
||||
return "1 месяц"
|
||||
elif days == 60:
|
||||
@@ -59,7 +81,9 @@ def format_period_description(days: int, language: str = "ru") -> str:
|
||||
else:
|
||||
month_word = "месяц" if months == 1 else ("месяца" if 2 <= months <= 4 else "месяцев")
|
||||
return f"{days} дней ({months} {month_word})"
|
||||
else:
|
||||
else:
|
||||
if days == 14:
|
||||
return "14 days"
|
||||
month_word = "month" if months == 1 else "months"
|
||||
return f"{days} days ({months} {month_word})"
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: remnawave_bot_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: remnawave_bot_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
bot:
|
||||
build: .
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DOCKER_ENV: "true"
|
||||
DATABASE_MODE: "auto"
|
||||
POSTGRES_HOST: "postgres"
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
|
||||
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
|
||||
TZ: "Europe/Moscow"
|
||||
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
|
||||
volumes:
|
||||
# Логи
|
||||
- ./logs:/app/logs:rw
|
||||
# Данные приложения (для SQLite в случае переключения)
|
||||
- ./data:/app/data:rw
|
||||
- ./locales:/app/locales:rw
|
||||
# Конфигурация приложения
|
||||
# - ./app-config.json:/app/app-config.json:ro
|
||||
# Timezone
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
# Логотип для сообщений
|
||||
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||
ports:
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
@@ -57,11 +57,13 @@ services:
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
|
||||
TZ: "Europe/Moscow"
|
||||
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
|
||||
volumes:
|
||||
# Логи
|
||||
- ./logs:/app/logs:rw
|
||||
# Данные приложения (для SQLite в случае переключения)
|
||||
- ./data:/app/data:rw
|
||||
- ./locales:/app/locales:rw
|
||||
# Конфигурация приложения
|
||||
# - ./app-config.json:/app/app-config.json:ro
|
||||
# Timezone
|
||||
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
{
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"ADMIN_MAIN_MENU": "🏠 Main menu",
|
||||
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
|
||||
"AUTOPAY_BUTTON": "💳 Auto payment",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
|
||||
"BACK": "⬅️ Back",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
|
||||
"CANCEL": "❌ Cancel",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
|
||||
"CHECK_STATUS_BUTTON": "📊 Check status",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
|
||||
"CONFIRM": "✅ Confirm",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
|
||||
"CONNECT_BUTTON": "🔗 Connect",
|
||||
"CONTINUE": "➡️ Continue",
|
||||
"CONTINUE_BUTTON": "➡️ Continue",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
|
||||
"CREATE_INVITE_BUTTON": "📝 Create invite",
|
||||
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
"DISABLE_BUTTON": "❌ Disable",
|
||||
"ENABLE_BUTTON": "✅ Enable",
|
||||
"ERROR": "❌ An error occurred",
|
||||
"ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.",
|
||||
"ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:",
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"MENU_BALANCE": "💰 Balance",
|
||||
"MENU_SUBSCRIPTION": "📱 Subscription",
|
||||
"MENU_TRIAL": "🎁 Trial subscription",
|
||||
"MY_BALANCE_BUTTON": "💰 My balance",
|
||||
"MY_SUBSCRIPTION_BUTTON": "📱 My subscription",
|
||||
"NO": "❌ No",
|
||||
"NO_SERVERS_AVAILABLE": "❌ No servers available",
|
||||
"NO_TRAFFIC_PACKAGES": "❌ No packages available",
|
||||
"OTHER_APPS_BUTTON": "📋 Other apps",
|
||||
"PAGINATION_NEXT": "➡️",
|
||||
"PAGINATION_PREV": "⬅️",
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
|
||||
"PAY_NOW_BUTTON": "💳 Pay",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
|
||||
"REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!",
|
||||
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Skip",
|
||||
"ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Referral list",
|
||||
"RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices",
|
||||
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device",
|
||||
"RESET_TRAFFIC_BUTTON": "🔄 Reset traffic",
|
||||
"RULES_HEADER": "📋 <b>Service Rules</b>",
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
|
||||
"SEND_CONTACT_BUTTON": "📱 Share contact",
|
||||
"SEND_LOCATION_BUTTON": "📍 Share location",
|
||||
"SHOW_QR_BUTTON": "📱 Show QR code",
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link",
|
||||
"SKIP_BUTTON": "Skip ➡️",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days",
|
||||
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
|
||||
"SUB_STATUS_NONE": "❌ Not available",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
|
||||
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!",
|
||||
"SUBSCRIPTION_ACTIVE": "✅ Active",
|
||||
"SUCCESS": "✅ Success",
|
||||
"REGISTRATION_COMPLETING": "✅ Completing registration...",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Top up balance",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
|
||||
"PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
|
||||
"STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.",
|
||||
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
|
||||
"WELCOME_FALLBACK": "Welcome, {user_name}!",
|
||||
"YES": "✅ Yes",
|
||||
"ACCESS_DENIED": "❌ Access denied",
|
||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||
"ADMIN_MONITORING": "🔍 Monitoring",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Administration panel</b>\n\nSelect a section to manage:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Promo codes",
|
||||
"ADMIN_REFERRALS": "🤝 Referral program",
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Rules",
|
||||
"ADMIN_STATISTICS": "📊 Statistics",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
|
||||
"MY_TICKETS_BUTTON": "📋 My tickets",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Contact support",
|
||||
"TICKET_PRIORITY_SELECT": "Select ticket priority:",
|
||||
"TICKET_PRIORITY_LOW": "🟢 Low",
|
||||
"TICKET_PRIORITY_NORMAL": "🟡 Normal",
|
||||
"TICKET_PRIORITY_HIGH": "🟠 High",
|
||||
"TICKET_PRIORITY_URGENT": "🔴 Urgent",
|
||||
"CANCEL_TICKET_CREATION": "❌ Cancel ticket creation",
|
||||
"TICKET_TITLE_INPUT": "Enter ticket title:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:",
|
||||
"TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:",
|
||||
"TICKET_MESSAGE_INPUT": "Now describe your problem or question:",
|
||||
"TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:",
|
||||
"TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.",
|
||||
"VIEW_TICKET": "👁️ View ticket",
|
||||
"BACK_TO_MENU": "🏠 Back to menu",
|
||||
"TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
|
||||
"NO_TICKETS": "You don't have any tickets yet.",
|
||||
"MY_TICKETS_TITLE": "📋 Your tickets:",
|
||||
"TICKET_STATUS_OPEN": "Open",
|
||||
"TICKET_STATUS_ANSWERED": "Answered",
|
||||
"TICKET_STATUS_CLOSED": "Closed",
|
||||
"TICKET_STATUS_PENDING": "Pending",
|
||||
"REPLY_TO_TICKET": "💬 Reply",
|
||||
"CLOSE_TICKET": "🔒 Close ticket",
|
||||
"CANCEL_REPLY": "❌ Cancel reply",
|
||||
"TICKET_REPLY_INPUT": "Enter your reply:",
|
||||
"TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:",
|
||||
"TICKET_REPLY_SENT": "✅ Your reply has been sent!",
|
||||
"TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.",
|
||||
"TICKET_CLOSED": "✅ Ticket closed.",
|
||||
"TICKET_CLOSE_ERROR": "❌ Error closing ticket.",
|
||||
"TICKET_NOT_FOUND": "Ticket not found.",
|
||||
"TICKET_CREATION_CANCELLED": "Ticket creation cancelled.",
|
||||
"BACK_TO_SUPPORT": "⬅️ Back to support",
|
||||
"TICKET_REPLY_CANCELLED": "Reply cancelled.",
|
||||
"BACK_TO_TICKETS": "⬅️ Back to tickets",
|
||||
"NO_TICKETS_ADMIN": "No tickets to display.",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Enter support reply:",
|
||||
|
||||
"ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!",
|
||||
"TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.",
|
||||
"TICKET_UPDATE_ERROR": "❌ Error updating ticket.",
|
||||
"MARK_AS_ANSWERED": "✅ Mark as answered",
|
||||
"TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:",
|
||||
"CLOSE_NOTIFICATION": "❌ Close notification",
|
||||
"NOTIFICATION_CLOSED": "Notification closed.",
|
||||
"UNBLOCK": "✅ Unblock",
|
||||
"BLOCK_FOREVER": "🚫 Block permanently",
|
||||
"BLOCK_BY_TIME": "⏳ Temporary block",
|
||||
"ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):",
|
||||
"TICKET_ATTACHMENTS": "📎 Attachments",
|
||||
"OPEN_TICKETS": "🔴 Open",
|
||||
"CLOSED_TICKETS": "🟢 Closed",
|
||||
"OPEN_TICKETS_HEADER": "🔴 Open tickets",
|
||||
"CLOSED_TICKETS_HEADER": "🟢 Closed tickets",
|
||||
"SENDING_ATTACHMENTS": "📎 Sending attachments...",
|
||||
"NO_ATTACHMENTS": "No attachments.",
|
||||
"ATTACHMENTS_SENT": "✅ Attachments sent.",
|
||||
"DELETE_MESSAGE": "🗑 Delete",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>User promo group</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.",
|
||||
"ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:",
|
||||
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”",
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Promo group:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_USERS": "👥 Users",
|
||||
"AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically",
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Autopay failed</b>\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
|
||||
"BALANCE_BUTTON": "💰 Balance: {balance}",
|
||||
"BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽",
|
||||
"BALANCE_HISTORY": "📊 Transaction history",
|
||||
"BALANCE_INFO": "\n💰 <b>Balance: {balance}</b>\n\nChoose an action:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
|
||||
"BALANCE_TOP_UP": "💳 Top up",
|
||||
"CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.",
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Subscription setup</b>\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n",
|
||||
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Your promo group discounts</b>",
|
||||
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n",
|
||||
"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",
|
||||
"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})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed",
|
||||
"INVALID_AMOUNT": "❌ Invalid amount",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n",
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n",
|
||||
"MENU_ADMIN": "⚙️ Admin panel",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
"MENU_SUPPORT": "🛠️ Support",
|
||||
"OPERATION_CANCELLED": "❌ Operation cancelled",
|
||||
"PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"PROMOCODE_ENTER": "🎫 Enter promo code",
|
||||
"PROMOCODE_EXPIRED": "❌ Promo code has expired",
|
||||
"PROMOCODE_INVALID": "❌ Invalid promo code",
|
||||
"PROMOCODE_SUCCESS": "🎉 Promo code applied!",
|
||||
"PROMOCODE_USED": "ℹ️ Promo code has already been used",
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Referral program</b>\n\n👥 <b>Invited:</b> {referrals_count} friends\n💰 <b>Earned:</b> {earned_amount}\n\n🔗 <b>Your referral link:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Your promo code:</b>\n<code>{referral_code}</code>\n\n💰 <b>Terms:</b>\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Invitation to the VPN service</b>\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n",
|
||||
"RULES_ACCEPT": "✅ I accept the rules",
|
||||
"RULES_DECLINE": "❌ I do not accept",
|
||||
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
|
||||
"SELECT_COUNTRIES": "Select countries:",
|
||||
"SELECT_DEVICES": "Number of devices:",
|
||||
"SELECT_PERIOD": "Choose period:",
|
||||
"SELECT_TRAFFIC": "Choose traffic package:",
|
||||
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew it to restore access.\n",
|
||||
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Subscription expiring!</b>\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Subscription expires in {days_text}!</b>\n\nYour paid subscription ends on {end_date}.\n\n💳 <b>Autopay:</b> {autopay_status}\n\n{action_text}\n",
|
||||
"SUBSCRIPTION_INFO": "\n📱 <b>Subscription details</b>\n\n📊 <b>Status:</b> {status}\n🎭 <b>Type:</b> {type}\n📅 <b>Valid until:</b> {end_date}\n⏰ <b>Days left:</b> {days_left}\n\n📈 <b>Traffic:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Servers:</b> {countries_count} countries\n📱 <b>Devices:</b> {devices_used} / {devices_limit}\n\n💳 <b>Autopay:</b> {autopay_status}\n",
|
||||
"SUBSCRIPTION_NONE": "❌ No active subscription",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Final configuration</b>\n\n📅 <b>Period:</b> {period} days\n📈 <b>Traffic:</b> {traffic}\n🌍 <b>Countries:</b> {countries}\n📱 <b>Devices:</b> {devices}\n\n💰 <b>Total:</b> {total_price}\n\nConfirm the purchase?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Trial subscription",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Technical support</b>\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Confirm traffic change</b>\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Switch traffic limit</b>\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 <b>Important:</b>\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n",
|
||||
"SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit",
|
||||
"TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):",
|
||||
"TOP_UP_METHODS": "\n💳 <b>Select a payment method</b>\n\nAmount: {amount}\n",
|
||||
"TOP_UP_STARS": "⭐ Telegram Stars",
|
||||
"TOP_UP_TRIBUTE": "💎 Bank card",
|
||||
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"USER_NOT_FOUND": "❌ User not found",
|
||||
"MENU_LANGUAGE": "🌐 Language",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
|
||||
"SUBSCRIPTION_STATUS_TRIAL": "Trial",
|
||||
"SUBSCRIPTION_STATUS_ACTIVE": "Active",
|
||||
"SUBSCRIPTION_STATUS_UNKNOWN": "Unknown",
|
||||
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired",
|
||||
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days",
|
||||
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr",
|
||||
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min",
|
||||
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!",
|
||||
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!",
|
||||
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!",
|
||||
"SUBSCRIPTION_TYPE_TRIAL": "Trial",
|
||||
"SUBSCRIPTION_TYPE_PAID": "Paid",
|
||||
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB",
|
||||
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB",
|
||||
"SUBSCRIPTION_NO_SERVERS": "No servers",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Connected devices:</b>\n",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Your import link for the VPN app:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.",
|
||||
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Step 2 - Add subscription:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Step 3 - Connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>How to connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Apps for {device_name}</b>",
|
||||
"SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:",
|
||||
"SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found",
|
||||
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
|
||||
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>How to use:</b>",
|
||||
"SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it",
|
||||
"SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app",
|
||||
"SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option",
|
||||
"SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link",
|
||||
"SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.",
|
||||
"REFERRAL_PROGRAM_TITLE": "👥 <b>Referral program</b>",
|
||||
"REFERRAL_STATS_HEADER": "📊 <b>Your statistics:</b>",
|
||||
"REFERRAL_STATS_INVITED": "• Invited users: <b>{count}</b>",
|
||||
"REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: <b>{count}</b>",
|
||||
"REFERRAL_STATS_ACTIVE": "• Active referrals: <b>{count}</b>",
|
||||
"REFERRAL_STATS_CONVERSION": "• Conversion: <b>{rate}%</b>",
|
||||
"REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: <b>{amount}</b>",
|
||||
"REFERRAL_STATS_MONTH_EARNED": "• Earned last month: <b>{amount}</b>",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>How rewards work:</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• New user receives: <b>{bonus}</b> on the first top-up from <b>{minimum}</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: <b>{percent}%</b>",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
|
||||
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Latest rewards:</b>",
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission",
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> from {referral_name}",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
|
||||
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
|
||||
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
|
||||
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
|
||||
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}",
|
||||
"REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}",
|
||||
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago",
|
||||
"REFERRAL_LIST_PREV_PAGE": "⬅️ Back",
|
||||
"REFERRAL_LIST_NEXT_PAGE": "Next ➡️",
|
||||
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Referral analytics</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Earnings by period:</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}",
|
||||
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Top {count} referrals:</b>",
|
||||
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)",
|
||||
"REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
|
||||
"REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection",
|
||||
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Share",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Invitation created!</b>",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
|
||||
}
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
{
|
||||
"ACCESS_DENIED": "❌ Доступ запрещен",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
|
||||
"ADMIN_MAIN_MENU": "🏠 Главное меню",
|
||||
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
"ADMIN_MONITORING": "🔍 Мониторинг",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Административная панель</b>\n\nВыберите раздел для управления:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Промокоды",
|
||||
"ADMIN_REFERRALS": "🤝 Партнерка",
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Правила",
|
||||
"ADMIN_STATISTICS": "📊 Статистика",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
|
||||
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой",
|
||||
"TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:",
|
||||
"TICKET_PRIORITY_LOW": "🟢 Низкий",
|
||||
"TICKET_PRIORITY_NORMAL": "🟡 Обычный",
|
||||
"TICKET_PRIORITY_HIGH": "🟠 Высокий",
|
||||
"TICKET_PRIORITY_URGENT": "🔴 Срочный",
|
||||
"CANCEL_TICKET_CREATION": "❌ Отменить создание тикета",
|
||||
"TICKET_TITLE_INPUT": "Введите заголовок тикета:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
|
||||
"TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото без текста:",
|
||||
"TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:",
|
||||
"TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.",
|
||||
"VIEW_TICKET": "👁️ Посмотреть тикет",
|
||||
"BACK_TO_MENU": "🏠 В главное меню",
|
||||
"TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
|
||||
"NO_TICKETS": "У вас пока нет тикетов.",
|
||||
"MY_TICKETS_TITLE": "📋 Ваши тикеты:",
|
||||
"TICKET_STATUS_OPEN": "Открыт",
|
||||
"TICKET_STATUS_ANSWERED": "Отвечен",
|
||||
"TICKET_STATUS_CLOSED": "Закрыт",
|
||||
"TICKET_STATUS_PENDING": "В ожидании",
|
||||
"REPLY_TO_TICKET": "💬 Ответить",
|
||||
"CLOSE_TICKET": "🔒 Закрыть тикет",
|
||||
"CANCEL_REPLY": "❌ Отменить ответ",
|
||||
"TICKET_REPLY_INPUT": "Введите ваш ответ:",
|
||||
"TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!",
|
||||
"TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.",
|
||||
"TICKET_CLOSED": "✅ Тикет закрыт.",
|
||||
"TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.",
|
||||
"TICKET_NOT_FOUND": "Тикет не найден.",
|
||||
"TICKET_CREATION_CANCELLED": "Создание тикета отменено.",
|
||||
"BACK_TO_SUPPORT": "⬅️ К поддержке",
|
||||
"TICKET_REPLY_CANCELLED": "Ответ отменен.",
|
||||
"BACK_TO_TICKETS": "⬅️ К тикетам",
|
||||
"NO_TICKETS_ADMIN": "Нет тикетов для отображения.",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
|
||||
|
||||
"ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!",
|
||||
"TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.",
|
||||
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
|
||||
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
|
||||
"TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:",
|
||||
"CLOSE_NOTIFICATION": "❌ Закрыть уведомление",
|
||||
"NOTIFICATION_CLOSED": "Уведомление закрыто.",
|
||||
"UNBLOCK": "✅ Разблокировать",
|
||||
"BLOCK_FOREVER": "🚫 Блок навсегда",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
"ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
|
||||
"TICKET_ATTACHMENTS": "📎 Вложения",
|
||||
"OPEN_TICKETS": "🔴 Открытые",
|
||||
"CLOSED_TICKETS": "🟢 Закрытые",
|
||||
"CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты",
|
||||
"OPEN_TICKETS_HEADER": "🔴 Открытые тикеты",
|
||||
"SENDING_ATTACHMENTS": "📎 Отправляю вложения...",
|
||||
"NO_ATTACHMENTS": "Вложений нет.",
|
||||
"ATTACHMENTS_SENT": "✅ Вложения отправлены.",
|
||||
"DELETE_MESSAGE": "🗑 Удалить",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>Промогруппа пользователя</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.",
|
||||
"ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:",
|
||||
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»",
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Промогруппа:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_USERS": "👥 Пользователи",
|
||||
"AUTOPAY_BUTTON": "💳 Автоплатёж",
|
||||
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
|
||||
"BALANCE_BUTTON": "💰 Баланс: {balance}",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}",
|
||||
"BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽",
|
||||
"BALANCE_HISTORY": "📊 История операций",
|
||||
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Настройка подписки</b>\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n",
|
||||
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Скидки вашей промогруппы</b>",
|
||||
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CANCEL": "❌ Отмена",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
|
||||
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
|
||||
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
|
||||
"CONFIRM": "✅ Подтвердить",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
|
||||
"CONNECT_BUTTON": "🔗 Подключиться",
|
||||
"CONTACT_SUPPORT": "💬 Написать в поддержку",
|
||||
"CONTINUE": "➡️ Продолжить",
|
||||
"CONTINUE_BUTTON": "✅ Продолжить",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
|
||||
"CREATE_INVITE": "📝 Создать приглашение",
|
||||
"CREATE_INVITE_BUTTON": "📝 Создать приглашение",
|
||||
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось",
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
"DISABLE_BUTTON": "❌ Выключить",
|
||||
"ENABLE_BUTTON": "✅ Включить",
|
||||
"ERROR": "❌ Произошла ошибка",
|
||||
"ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.",
|
||||
"ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:",
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
|
||||
"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_BUTTON": "🏠 Главное меню",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
|
||||
"MENU_ADMIN": "⚙️ Админ-панель",
|
||||
"MENU_BALANCE": "💰 Баланс",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
"MENU_RULES": "📋 Правила сервиса",
|
||||
"MENU_SUBSCRIPTION": "📱 Подписка",
|
||||
"MENU_SUPPORT": "🛠️ Техподдержка",
|
||||
"MENU_TRIAL": "🧪 Тестовая подписка",
|
||||
"MY_BALANCE_BUTTON": "💰 Мой баланс",
|
||||
"MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка",
|
||||
"NO": "❌ Нет",
|
||||
"NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов",
|
||||
"NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов",
|
||||
"OPERATION_CANCELLED": "❌ Операция отменена",
|
||||
"OTHER_APPS_BUTTON": "📋 Другие приложения",
|
||||
"PAGINATION_NEXT": "➡️",
|
||||
"PAGINATION_PREV": "⬅️",
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
|
||||
"PAY_NOW_BUTTON": "💳 Оплатить",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀",
|
||||
"PROMOCODE_ENTER": "🎫 Введите промокод:",
|
||||
"PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод",
|
||||
"PROMOCODE_EXPIRED": "❌ Промокод истек",
|
||||
"PROMOCODE_INVALID": "❌ Неверный промокод",
|
||||
"PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}",
|
||||
"PROMOCODE_USED": "❌ Промокод уже использован",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика",
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
|
||||
"REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!",
|
||||
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
|
||||
"ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Реферальная программа</b>\n\n👥 <b>Приглашено:</b> {referrals_count} друзей\n💰 <b>Заработано:</b> {earned_amount}\n\n🔗 <b>Ваша реферальная ссылка:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Условия:</b>\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в VPN сервис</b>\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
|
||||
"RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства",
|
||||
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство",
|
||||
"RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик",
|
||||
"RULES_ACCEPT": "✅ Принимаю правила",
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
|
||||
"RULES_DECLINE": "❌ Не принимаю",
|
||||
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
|
||||
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
|
||||
"SELECT_COUNTRIES": "Выберите страны:",
|
||||
"SELECT_DEVICES": "Количество устройств:",
|
||||
"SELECT_PERIOD": "Выберите период:",
|
||||
"SELECT_TRAFFIC": "Выберите пакет трафика:",
|
||||
"SEND_CONTACT_BUTTON": "📱 Отправить контакт",
|
||||
"SEND_LOCATION_BUTTON": "📍 Отправить геолокацию",
|
||||
"SHOW_QR_BUTTON": "📱 Показать QR код",
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки",
|
||||
"SKIP_BUTTON": "⏭️ Пропустить",
|
||||
"SUBSCRIPTION_ACTIVE": "✅ Активна",
|
||||
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Подписка истекла</b>\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n",
|
||||
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Подписка истекает!</b>\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\nВаша платная подписка истекает {end_date}.\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n\n{action_text}\n",
|
||||
"SUBSCRIPTION_INFO": "\n📱 <b>Информация о подписке</b>\n\n📊 <b>Статус:</b> {status}\n🎭 <b>Тип:</b> {type}\n📅 <b>Действует до:</b> {end_date}\n⏰ <b>Осталось дней:</b> {days_left}\n\n📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Серверы:</b> {countries_count} стран\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n",
|
||||
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.",
|
||||
"SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
|
||||
"SUB_STATUS_NONE": "❌ Отсутствует",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!",
|
||||
"SUCCESS": "✅ Успешно",
|
||||
"REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Техническая поддержка</b>\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Подтверждение переключения трафика</b>\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Переключение лимита трафика</b>\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n",
|
||||
"SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс",
|
||||
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
|
||||
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
|
||||
"TOP_UP_STARS": "⭐ Telegram Stars",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
|
||||
"STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.",
|
||||
"TOP_UP_TRIBUTE": "💎 Банковская карта",
|
||||
"TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
|
||||
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
"USER_NOT_FOUND": "❌ Пользователь не найден",
|
||||
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
|
||||
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
|
||||
"YES": "✅ Да",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Истекла",
|
||||
"SUBSCRIPTION_STATUS_TRIAL": "Тестовая",
|
||||
"SUBSCRIPTION_STATUS_ACTIVE": "Активна",
|
||||
"SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно",
|
||||
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк",
|
||||
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.",
|
||||
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.",
|
||||
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.",
|
||||
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!",
|
||||
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!",
|
||||
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!",
|
||||
"SUBSCRIPTION_TYPE_TRIAL": "Триал",
|
||||
"SUBSCRIPTION_TYPE_PAID": "Платная",
|
||||
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ",
|
||||
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ",
|
||||
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Подключенные устройства:</b>\n",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
|
||||
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Шаг 2 - Добавление подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Шаг 3 - Подключение:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>Как подключить:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Приложения для {device_name}</b>",
|
||||
"SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:",
|
||||
"SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено",
|
||||
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
|
||||
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>Как использовать:</b>",
|
||||
"SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать",
|
||||
"SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение",
|
||||
"SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
|
||||
"SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку",
|
||||
"SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
|
||||
"REFERRAL_PROGRAM_TITLE": "👥 <b>Реферальная программа</b>",
|
||||
"REFERRAL_STATS_HEADER": "📊 <b>Ваша статистика:</b>",
|
||||
"REFERRAL_STATS_INVITED": "• Приглашено пользователей: <b>{count}</b>",
|
||||
"REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: <b>{count}</b>",
|
||||
"REFERRAL_STATS_ACTIVE": "• Активных рефералов: <b>{count}</b>",
|
||||
"REFERRAL_STATS_CONVERSION": "• Конверсия: <b>{rate}%</b>",
|
||||
"REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: <b>{amount}</b>",
|
||||
"REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: <b>{amount}</b>",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>Как работают награды:</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
|
||||
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Последние начисления:</b>",
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки",
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> от {referral_name}",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
|
||||
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
|
||||
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
|
||||
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
|
||||
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}",
|
||||
"REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}",
|
||||
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно",
|
||||
"REFERRAL_LIST_PREV_PAGE": "⬅️ Назад",
|
||||
"REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️",
|
||||
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Аналитика рефералов</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Доходы по периодам:</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}",
|
||||
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Топ-{count} рефералов:</b>",
|
||||
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)",
|
||||
"REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
|
||||
"REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение",
|
||||
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Поделиться",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Приглашение создано!</b>",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
|
||||
}
|
||||
@@ -18,6 +18,7 @@ from app.external.webhook_server import WebhookServer
|
||||
from app.external.yookassa_webhook import start_yookassa_webhook_server
|
||||
from app.database.universal_migration import run_universal_migration
|
||||
from app.services.backup_service import backup_service
|
||||
from app.localization.loader import ensure_locale_templates
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -43,6 +44,11 @@ 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)
|
||||
|
||||
killer = GracefulExit()
|
||||
signal.signal(signal.SIGINT, killer.exit_gracefully)
|
||||
signal.signal(signal.SIGTERM, killer.exit_gracefully)
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""add promo groups table and link users"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
PROMO_GROUPS_TABLE = "promo_groups"
|
||||
USERS_TABLE = "users"
|
||||
PROMO_GROUP_COLUMN = "promo_group_id"
|
||||
PROMO_GROUP_INDEX = "ix_users_promo_group_id"
|
||||
PROMO_GROUP_FK = "fk_users_promo_group_id_promo_groups"
|
||||
DEFAULT_PROMO_GROUP_NAME = "Базовый юзер"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _foreign_key_exists(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool:
|
||||
return any(fk["name"] == fk_name for fk in inspector.get_foreign_keys(table_name))
|
||||
|
||||
revision: str = "1f5f3a3f5a4d"
|
||||
down_revision: Union[str, None] = "cbd1be472f3d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, PROMO_GROUPS_TABLE):
|
||||
op.create_table(
|
||||
PROMO_GROUPS_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"server_discount_percent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"traffic_discount_percent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"device_discount_percent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"is_default",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.UniqueConstraint("name", name="uq_promo_groups_name"),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
|
||||
op.add_column(
|
||||
USERS_TABLE,
|
||||
sa.Column(PROMO_GROUP_COLUMN, sa.Integer(), nullable=True),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
|
||||
if not _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX):
|
||||
op.create_index(PROMO_GROUP_INDEX, USERS_TABLE, [PROMO_GROUP_COLUMN])
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK):
|
||||
op.create_foreign_key(
|
||||
PROMO_GROUP_FK,
|
||||
USERS_TABLE,
|
||||
PROMO_GROUPS_TABLE,
|
||||
[PROMO_GROUP_COLUMN],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _table_exists(inspector, PROMO_GROUPS_TABLE) or not _column_exists(
|
||||
inspector, USERS_TABLE, PROMO_GROUP_COLUMN
|
||||
):
|
||||
return
|
||||
|
||||
promo_groups_table = sa.table(
|
||||
PROMO_GROUPS_TABLE,
|
||||
sa.column("id", sa.Integer()),
|
||||
sa.column("name", sa.String()),
|
||||
sa.column("server_discount_percent", sa.Integer()),
|
||||
sa.column("traffic_discount_percent", sa.Integer()),
|
||||
sa.column("device_discount_percent", sa.Integer()),
|
||||
sa.column("is_default", sa.Boolean()),
|
||||
)
|
||||
|
||||
connection = bind
|
||||
existing_named_group = (
|
||||
connection.execute(
|
||||
sa.select(
|
||||
promo_groups_table.c.id,
|
||||
promo_groups_table.c.is_default,
|
||||
)
|
||||
.where(promo_groups_table.c.name == DEFAULT_PROMO_GROUP_NAME)
|
||||
.limit(1)
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_named_group:
|
||||
default_group_id = existing_named_group["id"]
|
||||
if not existing_named_group["is_default"]:
|
||||
connection.execute(
|
||||
sa.update(promo_groups_table)
|
||||
.where(promo_groups_table.c.id == default_group_id)
|
||||
.values(is_default=True)
|
||||
)
|
||||
else:
|
||||
default_group_id = connection.execute(
|
||||
sa.select(promo_groups_table.c.id)
|
||||
.where(promo_groups_table.c.is_default.is_(True))
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if default_group_id is None:
|
||||
default_group_id = connection.execute(
|
||||
sa.insert(promo_groups_table)
|
||||
.values(
|
||||
name=DEFAULT_PROMO_GROUP_NAME,
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
)
|
||||
.returning(promo_groups_table.c.id)
|
||||
).scalar_one()
|
||||
|
||||
users_table = sa.table(
|
||||
USERS_TABLE,
|
||||
sa.column("promo_group_id", sa.Integer()),
|
||||
)
|
||||
connection.execute(
|
||||
sa.update(users_table)
|
||||
.where(users_table.c.promo_group_id.is_(None))
|
||||
.values(promo_group_id=default_group_id)
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
column_info = next(
|
||||
(col for col in inspector.get_columns(USERS_TABLE) if col["name"] == PROMO_GROUP_COLUMN),
|
||||
None,
|
||||
)
|
||||
if column_info and column_info.get("nullable", True):
|
||||
op.alter_column(
|
||||
USERS_TABLE,
|
||||
PROMO_GROUP_COLUMN,
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
|
||||
column_info = next(
|
||||
(
|
||||
col
|
||||
for col in inspector.get_columns(USERS_TABLE)
|
||||
if col["name"] == PROMO_GROUP_COLUMN
|
||||
),
|
||||
None,
|
||||
)
|
||||
if column_info and not column_info.get("nullable", False):
|
||||
op.alter_column(
|
||||
USERS_TABLE,
|
||||
PROMO_GROUP_COLUMN,
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK):
|
||||
op.drop_constraint(PROMO_GROUP_FK, USERS_TABLE, type_="foreignkey")
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX):
|
||||
op.drop_index(PROMO_GROUP_INDEX, table_name=USERS_TABLE)
|
||||
|
||||
op.drop_column(USERS_TABLE, PROMO_GROUP_COLUMN)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _table_exists(inspector, PROMO_GROUPS_TABLE):
|
||||
op.drop_table(PROMO_GROUPS_TABLE)
|
||||
@@ -1,29 +0,0 @@
|
||||
"""add_paid_price_to_subscription
|
||||
|
||||
Revision ID: 3d9b35c6bd8f
|
||||
Revises:
|
||||
Create Date: 2025-08-23 08:17:00.563340
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3d9b35c6bd8f'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('subscriptions', sa.Column('paid_price_kopeks', sa.Integer(), nullable=False, server_default='0'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('subscriptions', 'paid_price_kopeks')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,146 @@
|
||||
"""add advertising campaigns tables"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
CAMPAIGNS_TABLE = "advertising_campaigns"
|
||||
CAMPAIGNS_START_INDEX = "ix_advertising_campaigns_start_parameter"
|
||||
CAMPAIGNS_ID_INDEX = "ix_advertising_campaigns_id"
|
||||
REGISTRATIONS_TABLE = "advertising_campaign_registrations"
|
||||
REGISTRATIONS_ID_INDEX = "ix_advertising_campaign_registrations_id"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
revision: str = "5d1f1f8b2e9a"
|
||||
down_revision: Union[str, None] = "cbd1be472f3d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, CAMPAIGNS_TABLE):
|
||||
op.create_table(
|
||||
CAMPAIGNS_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("start_parameter", sa.String(length=64), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column(
|
||||
"balance_bonus_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_device_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_squads", sa.JSON(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("true"),
|
||||
),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX):
|
||||
op.create_index(
|
||||
CAMPAIGNS_START_INDEX,
|
||||
CAMPAIGNS_TABLE,
|
||||
["start_parameter"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX):
|
||||
op.create_index(CAMPAIGNS_ID_INDEX, CAMPAIGNS_TABLE, ["id"])
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _table_exists(inspector, REGISTRATIONS_TABLE):
|
||||
op.create_table(
|
||||
REGISTRATIONS_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("campaign_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column(
|
||||
"balance_bonus_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["campaign_id"],
|
||||
[f"{CAMPAIGNS_TABLE}.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX):
|
||||
op.create_index(
|
||||
REGISTRATIONS_ID_INDEX,
|
||||
REGISTRATIONS_TABLE,
|
||||
["id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX):
|
||||
op.drop_index(REGISTRATIONS_ID_INDEX, table_name=REGISTRATIONS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _table_exists(inspector, REGISTRATIONS_TABLE):
|
||||
op.drop_table(REGISTRATIONS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX):
|
||||
op.drop_index(CAMPAIGNS_ID_INDEX, table_name=CAMPAIGNS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX):
|
||||
op.drop_index(CAMPAIGNS_START_INDEX, table_name=CAMPAIGNS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _table_exists(inspector, CAMPAIGNS_TABLE):
|
||||
op.drop_table(CAMPAIGNS_TABLE)
|
||||
@@ -1,8 +1,11 @@
|
||||
"""add sent notifications table"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
|
||||
revision: str = '8fd1e338eb45'
|
||||
down_revision: Union[str, None] = '3d9b35c6bd8f'
|
||||
@@ -10,18 +13,46 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
TABLE_NAME = 'sent_notifications'
|
||||
UNIQUE_CONSTRAINT_NAME = 'uq_sent_notifications'
|
||||
UNIQUE_CONSTRAINT_COLUMNS = ['user_id', 'subscription_id', 'notification_type', 'days_before']
|
||||
|
||||
|
||||
def _table_exists(inspector: Inspector) -> bool:
|
||||
return TABLE_NAME in inspector.get_table_names()
|
||||
|
||||
|
||||
def _unique_constraint_exists(inspector: Inspector) -> bool:
|
||||
existing_constraints = {
|
||||
constraint['name'] for constraint in inspector.get_unique_constraints(TABLE_NAME)
|
||||
}
|
||||
return UNIQUE_CONSTRAINT_NAME in existing_constraints
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'sent_notifications',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
|
||||
sa.Column('notification_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('days_before', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('user_id', 'subscription_id', 'notification_type', 'days_before', name='uq_sent_notifications'),
|
||||
)
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector):
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
|
||||
sa.Column('notification_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('days_before', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint(*UNIQUE_CONSTRAINT_COLUMNS, name=UNIQUE_CONSTRAINT_NAME),
|
||||
)
|
||||
elif not _unique_constraint_exists(inspector):
|
||||
op.create_unique_constraint(
|
||||
UNIQUE_CONSTRAINT_NAME, TABLE_NAME, UNIQUE_CONSTRAINT_COLUMNS
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('sent_notifications')
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector):
|
||||
op.drop_table(TABLE_NAME)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"""add cascade delete to sent notifications"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'cbd1be472f3d'
|
||||
down_revision: Union[str, None] = '8fd1e338eb45'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint('sent_notifications_user_id_fkey', 'sent_notifications', type_='foreignkey')
|
||||
op.drop_constraint('sent_notifications_subscription_id_fkey', 'sent_notifications', type_='foreignkey')
|
||||
op.create_foreign_key('fk_sent_notifications_user_id_users', 'sent_notifications', 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('fk_sent_notifications_user_id_users', 'sent_notifications', type_='foreignkey')
|
||||
op.drop_constraint('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', type_='foreignkey')
|
||||
op.create_foreign_key('sent_notifications_user_id_fkey', 'sent_notifications', 'users', ['user_id'], ['id'])
|
||||
op.create_foreign_key('sent_notifications_subscription_id_fkey', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'])
|
||||
@@ -11,6 +11,7 @@ pydantic==2.11.9
|
||||
pydantic-settings==2.10.1
|
||||
python-dotenv==1.1.1
|
||||
redis==5.0.1
|
||||
PyYAML==6.0.2
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.7.0
|
||||
|
||||
Reference in New Issue
Block a user