Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae392691b1 | |||
| 35a202d280 | |||
| e9a2d8a232 | |||
| b76b99ca2e | |||
| 8960ea9c10 | |||
| 9f94acb580 | |||
| c9811fd518 | |||
| e25fb1b178 | |||
| 779a03d008 | |||
| f2a972aeea | |||
| 2a9163a28b | |||
| 20a628089d | |||
| cf01d917d1 | |||
| 1544da7e12 | |||
| 94a533508e | |||
| 98cdfcff81 | |||
| 23ddaa81be | |||
| 667e988c47 | |||
| ea6b20fefa | |||
| e734763eaf | |||
| cb98efc47e | |||
| dbd3d87779 | |||
| 533ae6dd80 | |||
| ca62321b42 | |||
| cd3d2c3731 | |||
| 8b8d29a75d | |||
| 3f97757ec0 | |||
| d5f3299bd2 | |||
| 0b99965731 | |||
| 8f679832fb | |||
| 46564a8e91 | |||
| 09b0dbb60c | |||
| 28d53e7e3f | |||
| 43de39f4ec | |||
| 49df0100c9 | |||
| c020f33c86 | |||
| 813daef2b1 | |||
| 13d4e934ec | |||
| 7721464611 | |||
| 9e42c3e7d1 | |||
| badbc150ab | |||
| 5d57c88e4b | |||
| ec18f82d26 | |||
| 846e5e6bf0 | |||
| 16b6df4d05 | |||
| 9e45bd6d75 | |||
| d01436bf14 | |||
| f50e170f8d | |||
| 2dd76d8bd2 | |||
| b9956391f0 | |||
| fdfd0e2355 | |||
| 548dd2fdc6 | |||
| cdcdd7abdc | |||
| d5c8795e75 | |||
| ceb0ac7538 | |||
| 305026c37d | |||
| f95367d12e | |||
| ac962c43e8 | |||
| 26efc70042 | |||
| 7f431e1485 | |||
| 1292046646 | |||
| d54b455007 | |||
| ca55d8942e | |||
| f00fdcb7c8 | |||
| 5443fdf064 | |||
| c33e49661c | |||
| 92bd0c362e | |||
| fe8cafdd99 | |||
| f0f8c4e4cf | |||
| f8e2e23082 | |||
| b915c65c41 | |||
| 4e7154e817 | |||
| 7992cf9e26 | |||
| ea958ea6c0 | |||
| 9b32de5fb2 | |||
| b71d0d058f | |||
| d36e353d9b | |||
| 02106bd64b | |||
| e68d6ae19a | |||
| 16ae480430 | |||
| 0b222c06a1 | |||
| ff340e90d6 | |||
| 4125141d01 | |||
| 2f40ad53b5 | |||
| 2d3674eff7 | |||
| 560e60b856 | |||
| 7cf20234b8 | |||
| ecab0a0f45 | |||
| 587722ed5c | |||
| bd7d9b3d5e | |||
| 60787a2aa9 | |||
| 9675ac3c14 | |||
| ca9a2f5997 | |||
| 652eb4f5a7 | |||
| 6ffbad747d | |||
| af46c596a9 | |||
| 1fcc218f26 | |||
| 39a5d674c9 | |||
| bdfe6ae1cf | |||
| 06e6c65ada |
+24
-10
@@ -190,6 +190,10 @@ CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
@@ -220,6 +224,26 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
# ===== СИСТЕМА БЕКАПОВ =====
|
||||
BACKUP_AUTO_ENABLED=true
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BACKUP_TIME=03:00
|
||||
BACKUP_MAX_KEEP=7
|
||||
BACKUP_COMPRESSION=true
|
||||
BACKUP_INCLUDE_LOGS=false
|
||||
BACKUP_LOCATION=/app/data/backups
|
||||
|
||||
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
@@ -228,13 +252,3 @@ LOG_FILE=logs/bot.log
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
|
||||
@@ -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.2.5-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.2.7-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.2.5-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.2.7-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.2.5-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.2.7-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
network=host
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -51,16 +52,30 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.2.5"
|
||||
VERSION="v2.2.7"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.2.5-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.2.7-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.2.5-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.2.7-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем, нужно ли пушить образ
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
|
||||
echo "should_push=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ PR из того же репозитория - будем пушить"
|
||||
else
|
||||
echo "should_push=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ PR из внешнего форка - только build без push"
|
||||
fi
|
||||
else
|
||||
echo "should_push=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Push/Tag - будем пушить"
|
||||
fi
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
@@ -83,7 +98,7 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
push: ${{ steps.version.outputs.should_push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
@@ -101,7 +116,7 @@ jobs:
|
||||
|
||||
- name: Generate security report
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true'
|
||||
with:
|
||||
command: quickview,compare
|
||||
image: ${{ steps.meta.outputs.tags }}
|
||||
@@ -112,7 +127,7 @@ jobs:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Summary
|
||||
if: github.event_name != 'pull_request'
|
||||
if: steps.version.outputs.should_push == 'true'
|
||||
run: |
|
||||
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -124,3 +139,16 @@ jobs:
|
||||
echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Build Summary (No Push)
|
||||
if: steps.version.outputs.should_push == 'false'
|
||||
run: |
|
||||
echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Статус** | ✅ Собран успешно (без публикации) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
*
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG VERSION="v2.2.5"
|
||||
ARG VERSION="v2.2.7"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
- 📈 **Масштабируемость** - от стартапа до крупного бизнеса
|
||||
- 🔧 **Мониторинг** - автоматическое управление режимом тех. работ
|
||||
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
|
||||
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомления в топики
|
||||
|
||||
---
|
||||
|
||||
@@ -70,7 +71,9 @@ cp .env.example .env
|
||||
nano .env # Заполни токены и настройки
|
||||
|
||||
# 3. Создай необходимые директории
|
||||
mkdir -p logs data
|
||||
mkdir -p ./logs ./data ./data/backups ./data/referral_qr
|
||||
chmod -R 755 ./logs ./data
|
||||
sudo chown -R 1000:1000 ./logs ./data
|
||||
|
||||
# 4. Запусти всё разом
|
||||
docker compose up -d
|
||||
@@ -218,25 +221,33 @@ SUPPORT_USERNAME=@support
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# ===== DATABASE =====
|
||||
# Для Docker используйте PostgreSQL:
|
||||
DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
# Для локального запуска без Docker используйте SQLite: sqlite+aiosqlite:///./bot.db
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
DATABASE_MODE=auto
|
||||
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
# Основной URL (можно оставить пустым для автоматического выбора)
|
||||
DATABASE_URL=
|
||||
|
||||
# Пароли для Docker (PostgreSQL/Redis)
|
||||
# PostgreSQL настройки (для Docker и кастомных установок)
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=remnawave_bot
|
||||
POSTGRES_USER=remnawave_user
|
||||
POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# SQLite настройки (для локального запуска)
|
||||
SQLITE_PATH=./data/bot.db
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=your_secret_key_here
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
@@ -250,7 +261,7 @@ TRIAL_SQUAD_UUID=
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
# Максимум устройств доступных к покупке (0 = Нет лимита)
|
||||
# Максимум устройств достопных к покупке (0 = Нет лимита)
|
||||
MAX_DEVICES_LIMIT=15
|
||||
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
@@ -309,18 +320,6 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=true
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
CRYPTOBOT_WEBHOOK_PORT=8083
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
@@ -341,8 +340,39 @@ YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
|
||||
|
||||
# Настройки чеков для налоговой
|
||||
YOOKASSA_VAT_CODE=1
|
||||
# Коды НДС:
|
||||
# 1 - НДС не облагается
|
||||
# 2 - НДС 0%
|
||||
# 3 - НДС 10%
|
||||
# 4 - НДС 20%
|
||||
# 5 - НДС 10/110
|
||||
# 6 - НДС 20/120
|
||||
|
||||
YOOKASSA_PAYMENT_MODE=full_payment
|
||||
# Способы расчета:
|
||||
# full_payment - полная оплата
|
||||
# partial_payment - частичная оплата
|
||||
# advance - аванс
|
||||
# full_prepayment - полная предоплата
|
||||
# partial_prepayment - частичная предоплата
|
||||
# credit - передача в кредит
|
||||
# credit_payment - оплата кредита
|
||||
|
||||
YOOKASSA_PAYMENT_SUBJECT=service
|
||||
# Предметы расчета:
|
||||
# commodity - товар
|
||||
# excise - подакцизный товар
|
||||
# job - работа
|
||||
# service - услуга
|
||||
# gambling_bet - ставка в азартной игре
|
||||
# gambling_prize - выигрыш в азартной игре
|
||||
# lottery - лотерейный билет
|
||||
# lottery_prize - выигрыш в лотерее
|
||||
# intellectual_activity - результат интеллектуальной деятельности
|
||||
# payment - платеж
|
||||
# agent_commission - агентское вознаграждение
|
||||
# composite - составной предмет расчета
|
||||
# another - другое
|
||||
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
@@ -350,18 +380,33 @@ YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION=Оплата подписки
|
||||
PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=true
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
CRYPTOBOT_WEBHOOK_PORT=8083
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - открывает ссылку подписки напрямую (режим 4)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
@@ -388,6 +433,26 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
# ===== СИСТЕМА БЕКАПОВ =====
|
||||
BACKUP_AUTO_ENABLED=true
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BACKUP_TIME=03:00
|
||||
BACKUP_MAX_KEEP=7
|
||||
BACKUP_COMPRESSION=true
|
||||
BACKUP_INCLUDE_LOGS=false
|
||||
BACKUP_LOCATION=/app/data/backups
|
||||
|
||||
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
@@ -396,16 +461,6 @@ LOG_FILE=logs/bot.log
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -435,6 +490,7 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
- ⭐ Telegram Stars
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa
|
||||
- 💰 CryptoBot
|
||||
- 🎁 Реферальные бонусы
|
||||
- Детальная история транзакций
|
||||
|
||||
@@ -489,6 +545,7 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
- 🔔 Автоуведомления о продлении
|
||||
- 💬 Система поддержки с HTML разметкой
|
||||
- 📝 Настройка правил сервиса
|
||||
- Настраиваемое приветственное сообщение с предложением активации триала
|
||||
|
||||
📨 **Уведомления в закрытый канал**
|
||||
- 🎯 Активация триала
|
||||
@@ -496,6 +553,14 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
- 🔄 Конверсия из триала в платную
|
||||
- ⏰ Продление подписки
|
||||
- 💰 Пополнение баланса
|
||||
- ♻️ Выход обновлений бота
|
||||
- 🚧 Потеря соелинения с апи Remnawave
|
||||
- 🗄️ **Бекапы/Восстановление бд**
|
||||
|
||||
🗄️ **Бекапы/Восстановление**
|
||||
- Ручной запуск бекапа
|
||||
- Восстановление бд
|
||||
- Включение/Отключение автобекапов
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -761,11 +826,13 @@ bedolaga_bot/
|
||||
│ │ ├── 💬 support.py # Техподдержка
|
||||
│ │ └── 👑 admin/ # Админ панель
|
||||
│ │ ├── 📊 statistics.py # Статистика
|
||||
│ │ ├── 🗄️ backup.py # Бекапы
|
||||
│ │ ├── 👥 users.py # Управление юзерами
|
||||
│ │ ├── 🎫 promocodes.py # Управление промокодами
|
||||
│ │ ├── 🚧 maintenance.py # Тех работы
|
||||
│ │ ├── 📨 messages.py # Рассылки
|
||||
│ │ ├── 📨 user_messages.py # Рандомные сообщения в меню
|
||||
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
|
||||
│ │ ├── ⚙️ main.py # Админское меню
|
||||
│ │ ├── 📖 rules.py # Правила
|
||||
│ │ ├── 🙋 referrals.py # Правила
|
||||
@@ -785,7 +852,8 @@ bedolaga_bot/
|
||||
│ │ ├── 📜 rules.py # Правила сервиса
|
||||
│ │ ├── 📜 subscription_conversion.py # Правила сервиса
|
||||
│ │ ├── 💳 yookassa.py # YooKassa операции
|
||||
│ │ ├── 💳 cryptobot.py # CryptoBot операции
|
||||
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
|
||||
│ │ ├── 💳 cryptobot.py # CryptoBot операции
|
||||
│ │ ├── 🌐 server_squad.py # Серверы и сквады
|
||||
│ │ ├── 🎁 promocode.py # Промокоды
|
||||
│ │ └── 👥 referral.py # Рефералы
|
||||
@@ -794,6 +862,7 @@ bedolaga_bot/
|
||||
│ │ ├── 👤 user_service.py # Сервис пользователей
|
||||
│ │ ├── 📋 subscription_service.py # Сервис подписок
|
||||
│ │ ├── 💰 payment_service.py # Платежи
|
||||
│ │ ├── 🗄️ backup_service.py # Бекапы
|
||||
│ │ ├── 🎁 promocode_service.py # Промокоды
|
||||
│ │ ├── 🚧 maintenance_service.py # Промокоды
|
||||
│ │ ├── 👥 referral_service.py # Рефералы
|
||||
@@ -932,6 +1001,10 @@ your-domain.com {
|
||||
handle /tribute-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /cryptobot-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /yookassa-webhook* {
|
||||
reverse_proxy localhost:8082
|
||||
|
||||
+8
-1
@@ -25,10 +25,15 @@ from app.handlers.admin import (
|
||||
statistics as admin_statistics, servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates
|
||||
updates as admin_updates, backup as admin_backup,
|
||||
welcome_text as admin_welcome_text
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
from app.utils.message_patch import patch_message_methods
|
||||
|
||||
patch_message_methods()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -104,6 +109,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
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)
|
||||
|
||||
common.register_handlers(dp)
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ class Settings(BaseSettings):
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
ENABLE_LOGO_MODE: bool = True
|
||||
LOGO_FILE: str = "vpn_logo.png"
|
||||
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
@@ -156,6 +158,14 @@ class Settings(BaseSettings):
|
||||
VERSION_CHECK_ENABLED: bool = True
|
||||
VERSION_CHECK_REPO: str = "fr1ngg/remnawave-bedolaga-telegram-bot"
|
||||
VERSION_CHECK_INTERVAL_HOURS: int = 1
|
||||
|
||||
BACKUP_AUTO_ENABLED: bool = True
|
||||
BACKUP_INTERVAL_HOURS: int = 24
|
||||
BACKUP_TIME: str = "03:00"
|
||||
BACKUP_MAX_KEEP: int = 7
|
||||
BACKUP_COMPRESSION: bool = True
|
||||
BACKUP_INCLUDE_LOGS: bool = False
|
||||
BACKUP_LOCATION: str = "/app/data/backups"
|
||||
|
||||
@field_validator('LOG_FILE', mode='before')
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from app.database.models import SentNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def notification_sent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(SentNotification).where(
|
||||
SentNotification.user_id == user_id,
|
||||
SentNotification.subscription_id == subscription_id,
|
||||
SentNotification.notification_type == notification_type,
|
||||
SentNotification.days_before == days_before,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def record_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
) -> None:
|
||||
notification = SentNotification(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
notification_type=notification_type,
|
||||
days_before=days_before,
|
||||
)
|
||||
db.add(notification)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
SentNotification.subscription_id == subscription_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
@@ -6,9 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
@@ -121,10 +122,11 @@ async def extend_subscription(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
await clear_notifications(db, subscription.id)
|
||||
|
||||
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
|
||||
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WelcomeText
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WELCOME_TEXT_KEY = "welcome_text"
|
||||
|
||||
async def get_active_welcome_text(db: AsyncSession) -> Optional[str]:
|
||||
result = await db.execute(
|
||||
select(WelcomeText)
|
||||
.where(WelcomeText.is_active == True)
|
||||
.order_by(WelcomeText.updated_at.desc())
|
||||
)
|
||||
welcome_text = result.scalar_one_or_none()
|
||||
|
||||
if welcome_text:
|
||||
return welcome_text.text_content
|
||||
|
||||
return None
|
||||
|
||||
async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -> bool:
|
||||
try:
|
||||
await db.execute(
|
||||
update(WelcomeText).values(is_active=False)
|
||||
)
|
||||
|
||||
new_welcome_text = WelcomeText(
|
||||
text_content=text_content,
|
||||
is_active=True,
|
||||
created_by=admin_id
|
||||
)
|
||||
|
||||
db.add(new_welcome_text)
|
||||
await db.commit()
|
||||
await db.refresh(new_welcome_text)
|
||||
|
||||
logger.info(f"Установлен новый приветственный текст администратором {admin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при установке приветственного текста: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def get_current_welcome_text_or_default() -> str:
|
||||
return (
|
||||
f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! "
|
||||
f"Подключайтесь за минуту и забудьте о блокировках. "
|
||||
f"✅ До 1 Гбит/с скорость "
|
||||
f"✅ Умный VPN — можно не отключать для большинства российских сервисов "
|
||||
f"✅ Современные протоколы — максимум защиты и анонимности "
|
||||
f"👉 Всего 99₽/мес за 1 устройство "
|
||||
f"👇 Жмите кнопку и подключайтесь!"
|
||||
)
|
||||
|
||||
def replace_placeholders(text: str, user) -> str:
|
||||
first_name = getattr(user, 'first_name', None)
|
||||
username = getattr(user, 'username', None)
|
||||
|
||||
first_name = first_name.strip() if first_name else None
|
||||
username = username.strip() if username else None
|
||||
|
||||
user_name = first_name or username or "друг"
|
||||
display_first_name = first_name or "друг"
|
||||
display_username = f"@{username}" if username else (first_name or "друг")
|
||||
clean_username = username or first_name or "друг"
|
||||
|
||||
replacements = {
|
||||
'{user_name}': user_name,
|
||||
'{first_name}': display_first_name,
|
||||
'{username}': display_username,
|
||||
'{username_clean}': clean_username,
|
||||
'Egor': user_name
|
||||
}
|
||||
|
||||
result = text
|
||||
for placeholder, value in replacements.items():
|
||||
result = result.replace(placeholder, value)
|
||||
|
||||
return result
|
||||
|
||||
async def get_welcome_text_for_user(db: AsyncSession, user) -> str:
|
||||
"""Получает приветственный текст с заменой плейсхолдеров для конкретного пользователя"""
|
||||
welcome_text = await get_active_welcome_text(db)
|
||||
|
||||
if not welcome_text:
|
||||
welcome_text = await get_current_welcome_text_or_default()
|
||||
|
||||
if isinstance(user, str):
|
||||
class SimpleUser:
|
||||
def __init__(self, name):
|
||||
self.first_name = name
|
||||
self.username = None
|
||||
user = SimpleUser(user)
|
||||
|
||||
return replace_placeholders(welcome_text, user)
|
||||
|
||||
def get_available_placeholders() -> dict:
|
||||
return {
|
||||
'{user_name}': 'Имя или username пользователя (приоритет: имя → username → "друг")',
|
||||
'{first_name}': 'Только имя пользователя (или "друг" если не указано)',
|
||||
'{username}': 'Username с символом @ (или имя если username не указан)',
|
||||
'{username_clean}': 'Username без символа @ (или имя если username не указан)'
|
||||
}
|
||||
@@ -555,6 +555,20 @@ class MonitoringLog(Base):
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
|
||||
class SentNotification(Base):
|
||||
__tablename__ = "sent_notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
|
||||
notification_type = Column(String(50), nullable=False)
|
||||
days_before = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", backref="sent_notifications")
|
||||
subscription = relationship("Subscription", backref="sent_notifications")
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
@@ -646,3 +660,15 @@ class UserMessage(Base):
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserMessage(id={self.id}, active={self.is_active}, text='{self.message_text[:50]}...')>"
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
@@ -75,7 +75,6 @@ async def check_column_exists(table_name: str, column_name: str) -> bool:
|
||||
return False
|
||||
|
||||
async def create_cryptobot_payments_table():
|
||||
"""Создание таблицы cryptobot_payments"""
|
||||
table_exists = await check_table_exists('cryptobot_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица cryptobot_payments уже существует")
|
||||
@@ -178,7 +177,6 @@ async def create_cryptobot_payments_table():
|
||||
return False
|
||||
|
||||
async def create_user_messages_table():
|
||||
"""Создание таблицы user_messages"""
|
||||
table_exists = await check_table_exists('user_messages')
|
||||
if table_exists:
|
||||
logger.info("Таблица user_messages уже существует")
|
||||
@@ -250,6 +248,75 @@ async def create_user_messages_table():
|
||||
logger.error(f"Ошибка создания таблицы user_messages: {e}")
|
||||
return False
|
||||
|
||||
async def create_welcome_texts_table():
|
||||
table_exists = await check_table_exists('welcome_texts')
|
||||
if table_exists:
|
||||
logger.info("Таблица welcome_texts уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE welcome_texts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text_content TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_by INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
|
||||
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE welcome_texts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
text_content TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_by INTEGER NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
|
||||
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE welcome_texts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
text_content TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
|
||||
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица welcome_texts успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы welcome_texts: {e}")
|
||||
return False
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -344,7 +411,6 @@ async def add_referral_system_columns():
|
||||
return False
|
||||
|
||||
async def create_subscription_conversions_table():
|
||||
"""Создание таблицы subscription_conversions"""
|
||||
table_exists = await check_table_exists('subscription_conversions')
|
||||
if table_exists:
|
||||
logger.info("Таблица subscription_conversions уже существует")
|
||||
@@ -420,7 +486,6 @@ async def create_subscription_conversions_table():
|
||||
return False
|
||||
|
||||
async def fix_subscription_duplicates_universal():
|
||||
"""Исправление дублирующихся подписок"""
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Обнаружен тип базы данных: {db_type}")
|
||||
@@ -505,12 +570,10 @@ async def run_universal_migration():
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Тип базы данных: {db_type}")
|
||||
|
||||
# Миграция реферальной системы
|
||||
referral_migration_success = await add_referral_system_columns()
|
||||
if not referral_migration_success:
|
||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||
|
||||
# Создание таблицы CryptoBot payments
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===")
|
||||
cryptobot_created = await create_cryptobot_payments_table()
|
||||
if cryptobot_created:
|
||||
@@ -518,15 +581,20 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
|
||||
|
||||
# Создание таблицы user_messages
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===")
|
||||
user_messages_created = await create_user_messages_table()
|
||||
if user_messages_created:
|
||||
logger.info("✅ Таблица user_messages готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей user_messages")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===")
|
||||
welcome_texts_created = await create_welcome_texts_table()
|
||||
if welcome_texts_created:
|
||||
logger.info("✅ Таблица welcome_texts готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей welcome_texts")
|
||||
|
||||
# Обновление внешних ключей
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -534,7 +602,6 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с обновлением внешних ключей")
|
||||
|
||||
# Создание таблицы конверсий подписок
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ КОНВЕРСИЙ ПОДПИСОК ===")
|
||||
conversions_created = await create_subscription_conversions_table()
|
||||
if conversions_created:
|
||||
@@ -542,7 +609,6 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
|
||||
|
||||
# Проверка и исправление дублирующихся подписок
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
|
||||
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
|
||||
@@ -560,7 +626,6 @@ async def run_universal_migration():
|
||||
|
||||
deleted_count = await fix_subscription_duplicates_universal()
|
||||
|
||||
# Финальная проверка
|
||||
async with engine.begin() as conn:
|
||||
final_check = await conn.execute(text("""
|
||||
SELECT user_id, COUNT(*) as count
|
||||
@@ -593,20 +658,22 @@ async def check_migration_status():
|
||||
status = {
|
||||
"has_made_first_topup_column": False,
|
||||
"cryptobot_table": False,
|
||||
"user_messages_table": False,
|
||||
"welcome_texts_table": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False
|
||||
}
|
||||
|
||||
# Проверка колонки реферальной системы
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
# Проверка таблицы CryptoBot
|
||||
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
|
||||
|
||||
# Проверка таблицы конверсий подписок
|
||||
status["user_messages_table"] = await check_table_exists('user_messages')
|
||||
|
||||
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
|
||||
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
|
||||
# Проверка дублирующихся подписок
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(text("""
|
||||
SELECT COUNT(*) FROM (
|
||||
@@ -619,10 +686,11 @@ async def check_migration_status():
|
||||
duplicates_count = duplicates_check.fetchone()[0]
|
||||
status["subscription_duplicates"] = (duplicates_count == 0)
|
||||
|
||||
# Вывод результатов
|
||||
check_names = {
|
||||
"has_made_first_topup_column": "Колонка реферальной системы",
|
||||
"cryptobot_table": "Таблица CryptoBot payments",
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
}
|
||||
@@ -640,11 +708,13 @@ async def check_migration_status():
|
||||
async with engine.begin() as conn:
|
||||
conversions_count = await conn.execute(text("SELECT COUNT(*) FROM subscription_conversions"))
|
||||
users_count = await conn.execute(text("SELECT COUNT(*) FROM users"))
|
||||
welcome_texts_count = await conn.execute(text("SELECT COUNT(*) FROM welcome_texts"))
|
||||
|
||||
conv_count = conversions_count.fetchone()[0]
|
||||
usr_count = users_count.fetchone()[0]
|
||||
welcome_count = welcome_texts_count.fetchone()[0]
|
||||
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий записано")
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов")
|
||||
except Exception as stats_error:
|
||||
logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}")
|
||||
|
||||
|
||||
Vendored
+70
-9
@@ -83,6 +83,16 @@ class RemnaWaveNode:
|
||||
traffic_limit_bytes: Optional[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionInfo:
|
||||
is_found: bool
|
||||
user: Optional[Dict[str, Any]]
|
||||
links: List[str]
|
||||
ss_conf_links: Dict[str, str]
|
||||
subscription_url: str
|
||||
happ: Optional[Dict[str, str]]
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
||||
self.message = message
|
||||
@@ -123,7 +133,7 @@ class RemnaWaveAPI:
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"🔗 Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
@@ -139,15 +149,15 @@ class RemnaWaveAPI:
|
||||
if ':' in self.secret_key:
|
||||
key_name, key_value = self.secret_key.split(':', 1)
|
||||
cookies = {key_name: key_value}
|
||||
logger.debug(f"🍪 Используем куки: {key_name}=***")
|
||||
logger.debug(f"Используем куки: {key_name}=***")
|
||||
else:
|
||||
cookies = {self.secret_key: self.secret_key}
|
||||
logger.debug(f"🍪 Используем куки: {self.secret_key}=***")
|
||||
logger.debug(f"Используем куки: {self.secret_key}=***")
|
||||
|
||||
connector_kwargs = {}
|
||||
|
||||
if conn_type == "local":
|
||||
logger.debug("🏠 Использую локальные заголовки proxy")
|
||||
logger.debug("Используют локальные заголовки proxy")
|
||||
headers.update({
|
||||
'X-Forwarded-Host': 'localhost',
|
||||
'Host': 'localhost'
|
||||
@@ -158,10 +168,10 @@ class RemnaWaveAPI:
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
connector_kwargs['ssl'] = ssl_context
|
||||
logger.debug("🔓 SSL проверка отключена для локального HTTPS")
|
||||
logger.debug("SSL проверка отключена для локального HTTPS")
|
||||
|
||||
elif conn_type == "external":
|
||||
logger.debug("🌐 Использую внешнее подключение с полной SSL проверкой")
|
||||
logger.debug("Используют внешнее подключение с полной SSL проверкой")
|
||||
pass
|
||||
|
||||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||||
@@ -280,7 +290,10 @@ class RemnaWaveAPI:
|
||||
async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}')
|
||||
return [self._parse_user(user) for user in response['response']]
|
||||
users_data = response.get('response', [])
|
||||
if not users_data:
|
||||
return []
|
||||
return [self._parse_user(user) for user in users_data]
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
@@ -440,9 +453,47 @@ class RemnaWaveAPI:
|
||||
return response['response']['eventSent']
|
||||
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> Dict[str, Any]:
|
||||
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
|
||||
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
|
||||
return response['response']
|
||||
return self._parse_subscription_info(response['response'])
|
||||
|
||||
async def get_subscription_by_short_uuid(self, short_uuid: str) -> str:
|
||||
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}") as response:
|
||||
if response.status >= 400:
|
||||
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
|
||||
return await response.text()
|
||||
|
||||
async def get_subscription_by_client_type(self, short_uuid: str, client_type: str) -> str:
|
||||
valid_types = ["stash", "singbox", "singbox-legacy", "mihomo", "json", "v2ray-json", "clash"]
|
||||
if client_type not in valid_types:
|
||||
raise ValueError(f"Invalid client type. Must be one of: {valid_types}")
|
||||
|
||||
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}/{client_type}") as response:
|
||||
if response.status >= 400:
|
||||
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
|
||||
return await response.text()
|
||||
|
||||
async def get_subscription_links(self, short_uuid: str) -> Dict[str, str]:
|
||||
base_url = f"{self.base_url}/api/sub/{short_uuid}"
|
||||
|
||||
links = {
|
||||
"base": base_url,
|
||||
"stash": f"{base_url}/stash",
|
||||
"singbox": f"{base_url}/singbox",
|
||||
"singbox_legacy": f"{base_url}/singbox-legacy",
|
||||
"mihomo": f"{base_url}/mihomo",
|
||||
"json": f"{base_url}/json",
|
||||
"v2ray_json": f"{base_url}/v2ray-json",
|
||||
"clash": f"{base_url}/clash"
|
||||
}
|
||||
|
||||
return links
|
||||
|
||||
async def get_outline_subscription(self, short_uuid: str, encoded_tag: str) -> str:
|
||||
async with self.session.get(f"{self.base_url}/api/sub/outline/{short_uuid}/ss/{encoded_tag}") as response:
|
||||
if response.status >= 400:
|
||||
raise RemnaWaveAPIError(f"Failed to get outline subscription: {response.status}")
|
||||
return await response.text()
|
||||
|
||||
|
||||
async def get_system_stats(self) -> Dict[str, Any]:
|
||||
@@ -572,6 +623,16 @@ class RemnaWaveAPI:
|
||||
traffic_used_bytes=node_data.get('trafficUsedBytes'),
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes')
|
||||
)
|
||||
|
||||
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
|
||||
return SubscriptionInfo(
|
||||
is_found=data['isFound'],
|
||||
user=data.get('user'),
|
||||
links=data.get('links', []),
|
||||
ss_conf_links=data.get('ssConfLinks', {}),
|
||||
subscription_url=data.get('subscriptionUrl', ''),
|
||||
happ=data.get('happ')
|
||||
)
|
||||
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.backup_service import backup_service
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackupStates(StatesGroup):
|
||||
waiting_backup_file = State()
|
||||
waiting_settings_update = State()
|
||||
|
||||
|
||||
def get_backup_main_keyboard(language: str = "ru"):
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🚀 Создать бекап", callback_data="backup_create"),
|
||||
InlineKeyboardButton(text="📥 Восстановить", callback_data="backup_restore")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Список бекапов", callback_data="backup_list"),
|
||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5):
|
||||
keyboard = []
|
||||
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
page_backups = backups[start_idx:end_idx]
|
||||
|
||||
for backup in page_backups:
|
||||
try:
|
||||
if backup.get("timestamp"):
|
||||
dt = datetime.fromisoformat(backup["timestamp"].replace('Z', '+00:00'))
|
||||
date_str = dt.strftime("%d.%m %H:%M")
|
||||
else:
|
||||
date_str = "?"
|
||||
except:
|
||||
date_str = "?"
|
||||
|
||||
size_str = f"{backup.get('file_size_mb', 0):.1f}MB"
|
||||
records_str = backup.get('total_records', '?')
|
||||
|
||||
button_text = f"📦 {date_str} • {size_str} • {records_str} записей"
|
||||
callback_data = f"backup_manage_{backup['filename']}"
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=button_text, callback_data=callback_data)])
|
||||
|
||||
if len(backups) > per_page:
|
||||
total_pages = (len(backups) + per_page - 1) // per_page
|
||||
nav_row = []
|
||||
|
||||
if page > 1:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"backup_list_page_{page-1}"))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="noop"))
|
||||
|
||||
if page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"backup_list_page_{page+1}"))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_backup_manage_keyboard(backup_filename: str):
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📥 Восстановить", callback_data=f"backup_restore_file_{backup_filename}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"backup_delete_{backup_filename}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="◀️ К списку", callback_data="backup_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_backup_settings_keyboard(settings_obj):
|
||||
auto_status = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены"
|
||||
compression_status = "✅ Включено" if settings_obj.compression_enabled else "❌ Отключено"
|
||||
logs_status = "✅ Включены" if settings_obj.include_logs else "❌ Отключены"
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🔄 Автобекапы: {auto_status}",
|
||||
callback_data="backup_toggle_auto"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🗜️ Сжатие: {compression_status}",
|
||||
callback_data="backup_toggle_compression"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"📋 Логи в бекапе: {logs_status}",
|
||||
callback_data="backup_toggle_logs"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_backup_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
|
||||
status_auto = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены"
|
||||
|
||||
text = f"""🗄️ <b>СИСТЕМА БЕКАПОВ</b>
|
||||
|
||||
📊 <b>Статус:</b>
|
||||
• Автобекапы: {status_auto}
|
||||
• Интервал: {settings_obj.backup_interval_hours} часов
|
||||
• Хранить: {settings_obj.max_backups_keep} файлов
|
||||
• Сжатие: {'Да' if settings_obj.compression_enabled else 'Нет'}
|
||||
|
||||
📁 <b>Расположение:</b> <code>/app/data/backups</code>
|
||||
|
||||
⚡ <b>Доступные операции:</b>
|
||||
• Создание полного бекапа всех данных
|
||||
• Восстановление из файла бекапа
|
||||
• Управление автоматическими бекапами
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def create_backup_handler(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("🔄 Создание бекапа запущено...")
|
||||
|
||||
progress_msg = await callback.message.edit_text(
|
||||
"🔄 <b>Создание бекапа...</b>\n\n"
|
||||
"⏳ Экспортируем данные из базы...\n"
|
||||
"Это может занять несколько минут.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Создаем бекап
|
||||
success, message, file_path = await backup_service.create_backup(
|
||||
created_by=db_user.telegram_id,
|
||||
compress=True
|
||||
)
|
||||
|
||||
if success:
|
||||
await progress_msg.edit_text(
|
||||
f"✅ <b>Бекап создан успешно!</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
else:
|
||||
await progress_msg.edit_text(
|
||||
f"❌ <b>Ошибка создания бекапа</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_backup_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = 1
|
||||
if callback.data.startswith("backup_list_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except:
|
||||
page = 1
|
||||
|
||||
backups = await backup_service.get_backup_list()
|
||||
|
||||
if not backups:
|
||||
text = "📦 <b>Список бекапов пуст</b>\n\nБекапы еще не создавались."
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🚀 Создать первый бекап", callback_data="backup_create")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")]
|
||||
])
|
||||
else:
|
||||
text = f"📦 <b>Список бекапов</b> (всего: {len(backups)})\n\n"
|
||||
text += "Выберите бекап для управления:"
|
||||
keyboard = get_backup_list_keyboard(backups, page)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def manage_backup_file(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
filename = callback.data.replace("backup_manage_", "")
|
||||
|
||||
backups = await backup_service.get_backup_list()
|
||||
backup_info = None
|
||||
|
||||
for backup in backups:
|
||||
if backup["filename"] == filename:
|
||||
backup_info = backup
|
||||
break
|
||||
|
||||
if not backup_info:
|
||||
await callback.answer("❌ Файл бекапа не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if backup_info.get("timestamp"):
|
||||
dt = datetime.fromisoformat(backup_info["timestamp"].replace('Z', '+00:00'))
|
||||
date_str = dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
else:
|
||||
date_str = "Неизвестно"
|
||||
except:
|
||||
date_str = "Ошибка формата даты"
|
||||
|
||||
text = f"""📦 <b>Информация о бекапе</b>
|
||||
|
||||
📄 <b>Файл:</b> <code>{filename}</code>
|
||||
📅 <b>Создан:</b> {date_str}
|
||||
💾 <b>Размер:</b> {backup_info.get('file_size_mb', 0):.2f} MB
|
||||
📊 <b>Таблиц:</b> {backup_info.get('tables_count', '?')}
|
||||
📈 <b>Записей:</b> {backup_info.get('total_records', '?'):,}
|
||||
🗜️ <b>Сжатие:</b> {'Да' if backup_info.get('compressed') else 'Нет'}
|
||||
🗄️ <b>БД:</b> {backup_info.get('database_type', 'unknown')}
|
||||
"""
|
||||
|
||||
if backup_info.get("error"):
|
||||
text += f"\n⚠️ <b>Ошибка:</b> {backup_info['error']}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_manage_keyboard(filename)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_backup_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
filename = callback.data.replace("backup_delete_", "")
|
||||
|
||||
text = f"🗑️ <b>Удаление бекапа</b>\n\n"
|
||||
text += f"Вы уверены, что хотите удалить бекап?\n\n"
|
||||
text += f"📄 <code>{filename}</code>\n\n"
|
||||
text += "⚠️ <b>Это действие нельзя отменить!</b>"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"backup_delete_confirm_{filename}"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_backup_execute(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
filename = callback.data.replace("backup_delete_confirm_", "")
|
||||
|
||||
success, message = await backup_service.delete_backup(filename)
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ <b>Бекап удален</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 К списку бекапов", callback_data="backup_list")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>Ошибка удаления</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_manage_keyboard(filename)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def restore_backup_start(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
if callback.data.startswith("backup_restore_file_"):
|
||||
# Восстановление из конкретного файла
|
||||
filename = callback.data.replace("backup_restore_file_", "")
|
||||
|
||||
text = f"📥 <b>Восстановление из бекапа</b>\n\n"
|
||||
text += f"📄 <b>Файл:</b> <code>{filename}</code>\n\n"
|
||||
text += "⚠️ <b>ВНИМАНИЕ!</b>\n"
|
||||
text += "• Процесс может занять несколько минут\n"
|
||||
text += "• Рекомендуется создать бекап перед восстановлением\n"
|
||||
text += "• Существующие данные будут дополнены\n\n"
|
||||
text += "Продолжить восстановление?"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, восстановить", callback_data=f"backup_restore_execute_{filename}"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_clear_{filename}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}")
|
||||
]
|
||||
])
|
||||
else:
|
||||
text = """📥 <b>Восстановление из бекапа</b>
|
||||
|
||||
📎 Отправьте файл бекапа (.json или .json.gz)
|
||||
|
||||
⚠️ <b>ВАЖНО:</b>
|
||||
• Файл должен быть создан этой системой бекапов
|
||||
• Процесс может занять несколько минут
|
||||
• Рекомендуется создать бекап перед восстановлением
|
||||
|
||||
💡 Или выберите из существующих бекапов ниже."""
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 Выбрать из списка", callback_data="backup_list")],
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
|
||||
await state.set_state(BackupStates.waiting_backup_file)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def restore_backup_execute(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if callback.data.startswith("backup_restore_execute_"):
|
||||
filename = callback.data.replace("backup_restore_execute_", "")
|
||||
clear_existing = False
|
||||
elif callback.data.startswith("backup_restore_clear_"):
|
||||
filename = callback.data.replace("backup_restore_clear_", "")
|
||||
clear_existing = True
|
||||
else:
|
||||
await callback.answer("❌ Неверный формат команды", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer("🔄 Восстановление запущено...")
|
||||
|
||||
# Показываем прогресс
|
||||
action_text = "очисткой и восстановлением" if clear_existing else "восстановлением"
|
||||
progress_msg = await callback.message.edit_text(
|
||||
f"📥 <b>Восстановление из бекапа...</b>\n\n"
|
||||
f"⏳ Работаем с {action_text} данных...\n"
|
||||
f"📄 Файл: <code>{filename}</code>\n\n"
|
||||
f"Это может занять несколько минут.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
backup_path = backup_service.backup_dir / filename
|
||||
|
||||
success, message = await backup_service.restore_backup(
|
||||
str(backup_path),
|
||||
clear_existing=clear_existing
|
||||
)
|
||||
|
||||
if success:
|
||||
await progress_msg.edit_text(
|
||||
f"✅ <b>Восстановление завершено!</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
else:
|
||||
await progress_msg.edit_text(
|
||||
f"❌ <b>Ошибка восстановления</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_manage_keyboard(filename)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_backup_file_upload(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
if not message.document:
|
||||
await message.answer(
|
||||
"❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
document = message.document
|
||||
|
||||
if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')):
|
||||
await message.answer(
|
||||
"❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
if document.file_size > 50 * 1024 * 1024:
|
||||
await message.answer(
|
||||
"❌ Файл слишком большой (максимум 50MB)",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
file = await message.bot.get_file(document.file_id)
|
||||
|
||||
temp_path = backup_service.backup_dir / f"uploaded_{document.file_name}"
|
||||
|
||||
await message.bot.download_file(file.file_path, temp_path)
|
||||
|
||||
text = f"""📥 <b>Файл загружен</b>
|
||||
|
||||
📄 <b>Имя:</b> <code>{document.file_name}</code>
|
||||
💾 <b>Размер:</b> {document.file_size / 1024 / 1024:.2f} MB
|
||||
|
||||
⚠️ <b>ВНИМАНИЕ!</b>
|
||||
Процесс восстановления изменит данные в базе.
|
||||
Рекомендуется создать бекап перед восстановлением.
|
||||
|
||||
Продолжить?"""
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Восстановить", callback_data=f"backup_restore_uploaded_{temp_path.name}"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_uploaded_clear_{temp_path.name}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")
|
||||
]
|
||||
])
|
||||
|
||||
await message.answer(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки файла бекапа: {e}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка загрузки файла: {str(e)}",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_backup_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
|
||||
text = f"""⚙️ <b>Настройки системы бекапов</b>
|
||||
|
||||
🔄 <b>Автоматические бекапы:</b>
|
||||
• Статус: {'✅ Включены' if settings_obj.auto_backup_enabled else '❌ Отключены'}
|
||||
• Интервал: {settings_obj.backup_interval_hours} часов
|
||||
• Время запуска: {settings_obj.backup_time}
|
||||
|
||||
📦 <b>Хранение:</b>
|
||||
• Максимум файлов: {settings_obj.max_backups_keep}
|
||||
• Сжатие: {'✅ Включено' if settings_obj.compression_enabled else '❌ Отключено'}
|
||||
• Включать логи: {'✅ Да' if settings_obj.include_logs else '❌ Нет'}
|
||||
|
||||
📁 <b>Расположение:</b> <code>{settings_obj.backup_location}</code>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_settings_keyboard(settings_obj)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_backup_setting(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
|
||||
if callback.data == "backup_toggle_auto":
|
||||
new_value = not settings_obj.auto_backup_enabled
|
||||
await backup_service.update_backup_settings(auto_backup_enabled=new_value)
|
||||
status = "включены" if new_value else "отключены"
|
||||
await callback.answer(f"Автобекапы {status}")
|
||||
|
||||
elif callback.data == "backup_toggle_compression":
|
||||
new_value = not settings_obj.compression_enabled
|
||||
await backup_service.update_backup_settings(compression_enabled=new_value)
|
||||
status = "включено" if new_value else "отключено"
|
||||
await callback.answer(f"Сжатие {status}")
|
||||
|
||||
elif callback.data == "backup_toggle_logs":
|
||||
new_value = not settings_obj.include_logs
|
||||
await backup_service.update_backup_settings(include_logs=new_value)
|
||||
status = "включены" if new_value else "отключены"
|
||||
await callback.answer(f"Логи в бекапе {status}")
|
||||
|
||||
await show_backup_settings(callback, db_user, db)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_backup_panel,
|
||||
F.data == "backup_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
create_backup_handler,
|
||||
F.data == "backup_create"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_backup_list,
|
||||
F.data.startswith("backup_list")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
manage_backup_file,
|
||||
F.data.startswith("backup_manage_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_backup_confirm,
|
||||
F.data.startswith("backup_delete_") & ~F.data.startswith("backup_delete_confirm_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_backup_execute,
|
||||
F.data.startswith("backup_delete_confirm_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
restore_backup_start,
|
||||
F.data.in_(["backup_restore"]) | F.data.startswith("backup_restore_file_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
restore_backup_execute,
|
||||
F.data.startswith("backup_restore_execute_") | F.data.startswith("backup_restore_clear_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_backup_settings,
|
||||
F.data == "backup_settings"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_backup_setting,
|
||||
F.data.in_(["backup_toggle_auto", "backup_toggle_compression", "backup_toggle_logs"])
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
handle_backup_file_upload,
|
||||
BackupStates.waiting_backup_file
|
||||
)
|
||||
@@ -0,0 +1,283 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.states import AdminStates
|
||||
from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.welcome_text import (
|
||||
get_active_welcome_text,
|
||||
set_welcome_text,
|
||||
get_current_welcome_text_or_default,
|
||||
get_available_placeholders
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_telegram_formatting_info() -> str:
|
||||
return """
|
||||
📝 <b>Поддерживаемые теги форматирования:</b>
|
||||
|
||||
• <code><b>жирный текст</b></code> → <b>жирный текст</b>
|
||||
• <code><i>курсив</i></code> → <i>курсив</i>
|
||||
• <code><u>подчеркнутый</u></code> → <u>подчеркнутый</u>
|
||||
• <code><s>зачеркнутый</s></code> → <s>зачеркнутый</s>
|
||||
• <code><code>моноширинный</code></code> → <code>моноширинный</code>
|
||||
• <code><pre>блок кода</pre></code> → многострочный код
|
||||
• <code><a href="URL">ссылка</a></code> → ссылка
|
||||
|
||||
⚠️ <b>ВНИМАНИЕ:</b> Используйте ТОЛЬКО указанные выше теги!
|
||||
Любые другие HTML-теги не поддерживаются и будут отображаться как обычный текст.
|
||||
|
||||
❌ <b>НЕ используйте:</b> <div>, <span>, <p>, <br>, <h1>-<h6>, <img> и другие HTML-теги.
|
||||
"""
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_welcome_text_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"👋 Управление приветственным текстом\n\n"
|
||||
"Здесь вы можете изменить текст, который показывается новым пользователям после регистрации.\n\n"
|
||||
"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_current_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_text = await get_active_welcome_text(db)
|
||||
|
||||
if not current_text:
|
||||
current_text = await get_current_welcome_text_or_default()
|
||||
status = "📝 Используется стандартный текст:"
|
||||
else:
|
||||
status = "📝 Текущий приветственный текст:"
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{status}\n\n"
|
||||
f"<code>{current_text}</code>\n\n"
|
||||
f"💡 Доступные плейсхолдеры:\n{placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_placeholders_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
help_text = (
|
||||
"💡 Доступные плейсхолдеры для автозамены:\n\n"
|
||||
f"{placeholders_text}\n\n"
|
||||
"📌 Примеры использования:\n"
|
||||
"• <code>Привет, {user_name}! Добро пожаловать!</code>\n"
|
||||
"• <code>Здравствуйте, {first_name}! Рады видеть вас!</code>\n"
|
||||
"• <code>Привет, {username}! Спасибо за регистрацию!</code>\n\n"
|
||||
"При отсутствии данных пользователя используется слово 'друг'."
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_formatting_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
formatting_info = get_telegram_formatting_info()
|
||||
|
||||
await callback.message.edit_text(
|
||||
formatting_info,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_text = await get_active_welcome_text(db)
|
||||
|
||||
if not current_text:
|
||||
current_text = await get_current_welcome_text_or_default()
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 Редактирование приветственного текста\n\n"
|
||||
f"Текущий текст:\n"
|
||||
f"<code>{current_text}</code>\n\n"
|
||||
f"💡 Доступные плейсхолдеры:\n{placeholders_text}\n\n"
|
||||
f"Отправьте новый текст:",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_welcome_text)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_welcome_text_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
new_text = message.text.strip()
|
||||
|
||||
if len(new_text) < 10:
|
||||
await message.answer("❌ Текст слишком короткий! Минимум 10 символов.")
|
||||
return
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
|
||||
return
|
||||
|
||||
success = await set_welcome_text(db, new_text, db_user.id)
|
||||
|
||||
if success:
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
|
||||
|
||||
await message.answer(
|
||||
f"✅ Приветственный текст успешно обновлен!\n\n"
|
||||
f"Новый текст:\n"
|
||||
f"<code>{new_text}</code>\n\n"
|
||||
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def reset_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
default_text = await get_current_welcome_text_or_default()
|
||||
success = await set_welcome_text(db, default_text, db_user.id)
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Приветственный текст сброшен на стандартный!\n\n"
|
||||
f"Стандартный текст:\n"
|
||||
f"<code>{default_text}</code>\n\n"
|
||||
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_preview_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
|
||||
class TestUser:
|
||||
def __init__(self):
|
||||
self.first_name = "Иван"
|
||||
self.username = "test_user"
|
||||
|
||||
test_user = TestUser()
|
||||
preview_text = await get_welcome_text_for_user(db, test_user)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
|
||||
f"<code>{preview_text}</code>",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
def register_welcome_text_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_welcome_text_panel,
|
||||
F.data == "welcome_text_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_current_welcome_text,
|
||||
F.data == "show_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_placeholders_help,
|
||||
F.data == "show_placeholders_help"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_formatting_help,
|
||||
F.data == "show_formatting_help"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_preview_welcome_text,
|
||||
F.data == "preview_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_edit_welcome_text,
|
||||
F.data == "edit_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
reset_welcome_text,
|
||||
F.data == "reset_welcome_text"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_welcome_text_edit,
|
||||
AdminStates.editing_welcome_text
|
||||
)
|
||||
+44
-46
@@ -11,6 +11,7 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_referral_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.utils.user_utils import (
|
||||
get_detailed_referral_list,
|
||||
get_referral_analytics,
|
||||
@@ -90,20 +91,12 @@ async def show_referral_info(
|
||||
referral_text += "\n"
|
||||
|
||||
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
|
||||
|
||||
if callback.message.text:
|
||||
await callback.message.edit_text(
|
||||
referral_text,
|
||||
reply_markup=get_referral_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
referral_text,
|
||||
reply_markup=get_referral_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
referral_text,
|
||||
get_referral_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -111,6 +104,8 @@ async def show_referral_qr(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
):
|
||||
await callback.answer()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
@@ -144,7 +139,6 @@ async def show_referral_qr(
|
||||
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_detailed_referral_list(
|
||||
@@ -154,15 +148,17 @@ async def show_detailed_referral_list(
|
||||
page: int = 1
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
referrals_data = await get_detailed_referral_list(db, db_user.id, limit=10, offset=(page - 1) * 10)
|
||||
|
||||
|
||||
if not referrals_data['referrals']:
|
||||
await callback.message.edit_text(
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
|
||||
])
|
||||
types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
|
||||
),
|
||||
parse_mode=None,
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
@@ -205,14 +201,14 @@ async def show_detailed_referral_list(
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
text=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)])
|
||||
|
||||
await callback.message.edit_text(
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -223,31 +219,31 @@ async def show_referral_analytics(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
analytics = await get_referral_analytics(db, db_user.id)
|
||||
|
||||
|
||||
text = f"📊 <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"
|
||||
|
||||
|
||||
if analytics['top_referrals']:
|
||||
text += f"🏆 <b>Топ-{len(analytics['top_referrals'])} рефералов:</b>\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 += "\n"
|
||||
|
||||
|
||||
text += "📈 Продолжайте развивать свою реферальную сеть!"
|
||||
|
||||
await callback.message.edit_text(
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -257,34 +253,36 @@ async def create_invite_message(
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="📤 Поделиться",
|
||||
switch_inline_query=invite_text
|
||||
switch_inline_query=invite_text
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Приглашение создано!</b>\n\n"
|
||||
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
|
||||
f"<code>{invite_text}</code>",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
(
|
||||
f"📝 <b>Приглашение создано!</b>\n\n"
|
||||
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
|
||||
f"<code>{invite_text}</code>"
|
||||
),
|
||||
keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
+21
-86
@@ -12,7 +12,7 @@ from app.database.crud.user import (
|
||||
)
|
||||
from app.database.models import UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard
|
||||
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.referral_service import process_referral_registration
|
||||
@@ -388,7 +388,7 @@ async def complete_registration_from_callback(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
|
||||
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
|
||||
@@ -498,52 +498,20 @@ async def complete_registration_from_callback(
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
|
||||
menu_text = await get_main_menu_text_simple(user.full_name, texts, db)
|
||||
|
||||
user_name = callback.from_user.first_name or callback.from_user.username or "друг"
|
||||
offer_text = await get_welcome_text_for_user(db, user_name)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
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
|
||||
),
|
||||
parse_mode="HTML"
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке главного меню: {e}")
|
||||
try:
|
||||
balance_rubles = user.balance_kopeks // 100
|
||||
await callback.message.answer(
|
||||
f"Добро пожаловать, {user.full_name}!\n"
|
||||
f"Баланс: {balance_rubles} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
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
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user.telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await callback.message.answer(f"Добро пожаловать, {user.full_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user.telegram_id}")
|
||||
except Exception as final_error:
|
||||
logger.error(f"⛔ Критическая ошибка при отправке простого сообщения: {final_error}")
|
||||
|
||||
logger.error(f"Ошибка при отправке предложения триала: {e}")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
|
||||
|
||||
async def complete_registration(
|
||||
@@ -551,7 +519,7 @@ async def complete_registration(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
|
||||
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
@@ -661,53 +629,19 @@ async def complete_registration(
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
|
||||
menu_text = await get_main_menu_text_simple(user.full_name, texts, db)
|
||||
|
||||
offer_text = await get_welcome_text_for_user(db, message.from_user)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
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
|
||||
),
|
||||
parse_mode="HTML"
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке главного меню: {e}")
|
||||
try:
|
||||
balance_rubles = user.balance_kopeks // 100
|
||||
await message.answer(
|
||||
f"Добро пожаловать, {user.full_name}!\n"
|
||||
f"Баланс: {balance_rubles} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
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
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user.telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await message.answer(f"Добро пожаловать, {user.full_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user.telegram_id}")
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.error(f"Ошибка при отправке предложения триала: {e}")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
|
||||
|
||||
|
||||
@@ -857,3 +791,4 @@ def register_handlers(dp: Dispatcher):
|
||||
logger.info("✅ Зарегистрирован handle_potential_referral_code")
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
|
||||
|
||||
+844
-277
File diff suppressed because it is too large
Load Diff
+23
-1
@@ -30,7 +30,12 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel"),
|
||||
InlineKeyboardButton(text="🔄 Обновления", callback_data="admin_updates")
|
||||
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel")
|
||||
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновления", callback_data="admin_updates"),
|
||||
InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
@@ -660,3 +665,20 @@ def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_welcome_text_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📝 Изменить текст", callback_data="edit_welcome_text"),
|
||||
InlineKeyboardButton(text="👁️ Показать текущий", callback_data="show_welcome_text")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🏷️ HTML форматирование", callback_data="show_formatting_help"),
|
||||
InlineKeyboardButton(text="💡 Плейсхолдеры", callback_data="show_placeholders_help")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Сбросить", callback_data="reset_welcome_text"),
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
+237
-74
@@ -2,9 +2,12 @@ from typing import List, Optional
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from datetime import datetime
|
||||
from app.database.models import User
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,8 +22,19 @@ def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_post_registration_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🚀 Подключиться бесплатно 🚀", callback_data="menu_trial"
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text="Пропустить ➡️", callback_data="back_to_menu")],
|
||||
])
|
||||
|
||||
|
||||
def get_main_menu_keyboard(
|
||||
language: str = "ru",
|
||||
language: str = "ru",
|
||||
is_admin: bool = False,
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
@@ -127,7 +141,7 @@ def get_subscription_keyboard(
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
if has_subscription:
|
||||
if subscription and subscription.subscription_url:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
@@ -151,21 +165,23 @@ def get_subscription_keyboard(
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
])
|
||||
|
||||
if not is_trial and subscription and subscription.days_left <= 3:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⏰ Продлить", callback_data="subscription_extend")
|
||||
])
|
||||
|
||||
|
||||
if not is_trial:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend")
|
||||
])
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="💳 Автоплатеж", callback_data="subscription_autopay")
|
||||
])
|
||||
|
||||
|
||||
if is_trial:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
|
||||
@@ -181,40 +197,6 @@ def get_subscription_keyboard(
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if show_countries_management:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices")
|
||||
]
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")
|
||||
])
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
@@ -526,7 +508,7 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⏰ Продлить подписку",
|
||||
text=texts.MENU_EXTEND_SUBSCRIPTION,
|
||||
callback_data="subscription_extend"
|
||||
)
|
||||
],
|
||||
@@ -764,7 +746,7 @@ def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: dateti
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_add_devices_keyboard(current_devices: int, language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
|
||||
def get_change_devices_keyboard(current_devices: int, language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
@@ -779,32 +761,77 @@ def get_add_devices_keyboard(current_devices: int, language: str = "ru", subscri
|
||||
|
||||
buttons = []
|
||||
|
||||
for count in [1, 2, 3, 4, 5]:
|
||||
new_total = current_devices + count
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and new_total > settings.MAX_DEVICES_LIMIT:
|
||||
continue
|
||||
|
||||
price_per_month = count * device_price_per_month
|
||||
total_price = price_per_month * months_multiplier
|
||||
|
||||
if language == "ru":
|
||||
text = f"📱 +{count} устройство(а) (итого: {new_total}) - {total_price//100} ₽{period_text}"
|
||||
min_devices = 1
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
|
||||
|
||||
start_range = max(1, min(current_devices - 3, max_devices - 6))
|
||||
end_range = min(max_devices + 1, max(current_devices + 4, 7))
|
||||
|
||||
for devices_count in range(start_range, end_range):
|
||||
if devices_count == current_devices:
|
||||
emoji = "✅"
|
||||
action_text = " (текущее)"
|
||||
price_text = ""
|
||||
elif devices_count > current_devices:
|
||||
emoji = "➕"
|
||||
additional_devices = devices_count - current_devices
|
||||
|
||||
current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT)
|
||||
chargeable_devices = new_chargeable - current_chargeable
|
||||
|
||||
if chargeable_devices > 0:
|
||||
price_per_month = chargeable_devices * device_price_per_month
|
||||
total_price = price_per_month * months_multiplier
|
||||
price_text = f" (+{total_price//100}₽{period_text})"
|
||||
action_text = ""
|
||||
else:
|
||||
price_text = " (бесплатно)"
|
||||
action_text = ""
|
||||
else:
|
||||
text = f"📱 +{count} device(s) (total: {new_total}) - {total_price//100} ₽{period_text}"
|
||||
emoji = "➖"
|
||||
action_text = ""
|
||||
price_text = " (без возврата)"
|
||||
|
||||
button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"add_devices_{count}")
|
||||
InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}")
|
||||
])
|
||||
|
||||
if current_devices < start_range or current_devices >= end_range:
|
||||
current_button = f"✅ {current_devices} устр. (текущее)"
|
||||
buttons.insert(0, [
|
||||
InlineKeyboardButton(text=current_button, callback_data=f"change_devices_{current_devices}")
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="menu_subscription"
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="subscription_settings"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Подтвердить изменение",
|
||||
callback_data=f"confirm_change_devices_{new_devices_count}_{price}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data="subscription_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
@@ -1047,31 +1074,23 @@ def get_specific_app_keyboard(
|
||||
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
period_display = {
|
||||
14: "14 дней",
|
||||
30: "30 дней",
|
||||
60: "60 дней",
|
||||
90: "90 дней",
|
||||
180: "180 дней",
|
||||
360: "360 дней"
|
||||
}
|
||||
|
||||
|
||||
for days in available_periods:
|
||||
if days in prices and days in period_display:
|
||||
if days in prices:
|
||||
period_display = format_period_description(days, language)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display[days]} - {texts.format_price(prices[days])}",
|
||||
text=f"📅 {period_display} - {texts.format_price(prices[days])}",
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_cryptobot_payment_keyboard(
|
||||
@@ -1103,3 +1122,147 @@ def get_cryptobot_payment_keyboard(
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
def get_devices_management_keyboard(
|
||||
devices: List[dict],
|
||||
pagination,
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
|
||||
keyboard = []
|
||||
|
||||
for i, device in enumerate(devices):
|
||||
platform = device.get('platform', 'Unknown')
|
||||
device_model = device.get('deviceModel', 'Unknown')
|
||||
device_info = f"{platform} - {device_model}"
|
||||
|
||||
if len(device_info) > 25:
|
||||
device_info = device_info[:22] + "..."
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"🔄 {device_info}",
|
||||
callback_data=f"reset_device_{i}_{pagination.page}"
|
||||
)
|
||||
])
|
||||
|
||||
if pagination.total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if pagination.has_prev:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"devices_page_{pagination.prev_page}"
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{pagination.page}/{pagination.total_pages}",
|
||||
callback_data="current_page"
|
||||
)
|
||||
)
|
||||
|
||||
if pagination.has_next:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"devices_page_{pagination.next_page}"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Сбросить все устройства",
|
||||
callback_data="reset_all_devices"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="subscription_settings"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_updated_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if show_countries_management:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🌐 Добавить страны", callback_data="subscription_add_countries")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Изменить устройства", callback_data="subscription_change_devices")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔧 Управление устройствами", callback_data="subscription_manage_devices")
|
||||
]
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="🔄 Переключить трафик", callback_data="subscription_switch_traffic")
|
||||
])
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_device_reset_confirm_keyboard(device_info: str, device_index: int, page: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да, сбросить это устройство",
|
||||
callback_data=f"confirm_reset_device_{device_index}_{page}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"devices_page_{page}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_device_management_help_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❓ Как подключить устройство заново?",
|
||||
callback_data="device_connection_help"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Управление устройствами",
|
||||
callback_data="subscription_manage_devices"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ К подписке",
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
@@ -162,6 +162,50 @@ class RussianTexts(Texts):
|
||||
|
||||
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>
|
||||
@@ -281,7 +325,7 @@ class RussianTexts(Texts):
|
||||
• 30 дней всего за {price}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
• Скорость до 1ГБит/сек
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
@@ -408,6 +452,48 @@ class RussianTexts(Texts):
|
||||
Ваша подписка автоматически продлена на {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>
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
import asyncio
|
||||
import json as json_lib
|
||||
import logging
|
||||
import gzip
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from dataclasses import dataclass, asdict
|
||||
import aiofiles
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, inspect
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db, engine
|
||||
from app.database.models import (
|
||||
User, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog,
|
||||
SubscriptionConversion, SentNotification, BroadcastHistory,
|
||||
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
|
||||
CryptoBotPayment, Base
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupMetadata:
|
||||
timestamp: str
|
||||
version: str = "1.0"
|
||||
database_type: str = "postgresql"
|
||||
backup_type: str = "full"
|
||||
tables_count: int = 0
|
||||
total_records: int = 0
|
||||
compressed: bool = True
|
||||
file_size_bytes: int = 0
|
||||
created_by: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupSettings:
|
||||
auto_backup_enabled: bool = True
|
||||
backup_interval_hours: int = 24
|
||||
backup_time: str = "03:00"
|
||||
max_backups_keep: int = 7
|
||||
compression_enabled: bool = True
|
||||
include_logs: bool = False
|
||||
backup_location: str = "/app/data/backups"
|
||||
|
||||
|
||||
class BackupService:
|
||||
|
||||
def __init__(self, bot=None):
|
||||
self.bot = bot
|
||||
self.backup_dir = Path(settings.SQLITE_PATH).parent / "backups"
|
||||
self.backup_dir.mkdir(exist_ok=True)
|
||||
self._auto_backup_task = None
|
||||
self._settings = self._load_settings()
|
||||
|
||||
self.backup_models = [
|
||||
User, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, ServiceRule, SystemSetting,
|
||||
SubscriptionConversion, SentNotification, BroadcastHistory,
|
||||
ServerSquad, SubscriptionServer, UserMessage,
|
||||
YooKassaPayment, CryptoBotPayment
|
||||
]
|
||||
|
||||
if self._settings.include_logs:
|
||||
self.backup_models.append(MonitoringLog)
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
return BackupSettings(
|
||||
auto_backup_enabled=os.getenv("BACKUP_AUTO_ENABLED", "true").lower() == "true",
|
||||
backup_interval_hours=int(os.getenv("BACKUP_INTERVAL_HOURS", "24")),
|
||||
backup_time=os.getenv("BACKUP_TIME", "03:00"),
|
||||
max_backups_keep=int(os.getenv("BACKUP_MAX_KEEP", "7")),
|
||||
compression_enabled=os.getenv("BACKUP_COMPRESSION", "true").lower() == "true",
|
||||
include_logs=os.getenv("BACKUP_INCLUDE_LOGS", "false").lower() == "true",
|
||||
backup_location=os.getenv("BACKUP_LOCATION", "/app/data/backups")
|
||||
)
|
||||
|
||||
async def create_backup(
|
||||
self,
|
||||
created_by: Optional[int] = None,
|
||||
compress: bool = True,
|
||||
include_logs: bool = None
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
try:
|
||||
logger.info("🔄 Начинаем создание бекапа...")
|
||||
|
||||
if include_logs is None:
|
||||
include_logs = self._settings.include_logs
|
||||
|
||||
models_to_backup = self.backup_models.copy()
|
||||
if not include_logs and MonitoringLog in models_to_backup:
|
||||
models_to_backup.remove(MonitoringLog)
|
||||
elif include_logs and MonitoringLog not in models_to_backup:
|
||||
models_to_backup.append(MonitoringLog)
|
||||
|
||||
backup_data = {}
|
||||
total_records = 0
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
for model in models_to_backup:
|
||||
table_name = model.__tablename__
|
||||
logger.info(f"📊 Экспортируем таблицу: {table_name}")
|
||||
|
||||
result = await db.execute(select(model))
|
||||
records = result.scalars().all()
|
||||
|
||||
table_data = []
|
||||
for record in records:
|
||||
record_dict = {}
|
||||
for column in model.__table__.columns:
|
||||
value = getattr(record, column.name)
|
||||
|
||||
if isinstance(value, datetime):
|
||||
record_dict[column.name] = value.isoformat()
|
||||
elif hasattr(value, '__dict__'):
|
||||
record_dict[column.name] = str(value)
|
||||
else:
|
||||
record_dict[column.name] = value
|
||||
|
||||
table_data.append(record_dict)
|
||||
|
||||
backup_data[table_name] = table_data
|
||||
total_records += len(table_data)
|
||||
|
||||
logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
|
||||
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте данных: {e}")
|
||||
raise e
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
metadata = BackupMetadata(
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
database_type="postgresql" if settings.is_postgresql() else "sqlite",
|
||||
backup_type="full",
|
||||
tables_count=len(models_to_backup),
|
||||
total_records=total_records,
|
||||
compressed=compress,
|
||||
created_by=created_by,
|
||||
file_size_bytes=0
|
||||
)
|
||||
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"backup_{timestamp}.json"
|
||||
if compress:
|
||||
filename += ".gz"
|
||||
|
||||
backup_path = self.backup_dir / filename
|
||||
|
||||
backup_structure = {
|
||||
"metadata": asdict(metadata),
|
||||
"data": backup_data
|
||||
}
|
||||
|
||||
if compress:
|
||||
backup_json_str = json_lib.dumps(backup_structure, ensure_ascii=False, indent=2)
|
||||
async with aiofiles.open(backup_path, 'wb') as f:
|
||||
compressed_data = gzip.compress(backup_json_str.encode('utf-8'))
|
||||
await f.write(compressed_data)
|
||||
else:
|
||||
async with aiofiles.open(backup_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(json_lib.dumps(backup_structure, ensure_ascii=False, indent=2))
|
||||
|
||||
file_size = backup_path.stat().st_size
|
||||
backup_structure["metadata"]["file_size_bytes"] = file_size
|
||||
|
||||
if compress:
|
||||
backup_json_str = json_lib.dumps(backup_structure, ensure_ascii=False, indent=2)
|
||||
async with aiofiles.open(backup_path, 'wb') as f:
|
||||
compressed_data = gzip.compress(backup_json_str.encode('utf-8'))
|
||||
await f.write(compressed_data)
|
||||
else:
|
||||
async with aiofiles.open(backup_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(json_lib.dumps(backup_structure, ensure_ascii=False, indent=2))
|
||||
|
||||
await self._cleanup_old_backups()
|
||||
|
||||
size_mb = file_size / 1024 / 1024
|
||||
message = (f"✅ Бекап успешно создан!\n"
|
||||
f"📁 Файл: {filename}\n"
|
||||
f"📊 Таблиц: {len(models_to_backup)}\n"
|
||||
f"📈 Записей: {total_records:,}\n"
|
||||
f"💾 Размер: {size_mb:.2f} MB")
|
||||
|
||||
logger.info(message)
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification(
|
||||
"success", message, str(backup_path)
|
||||
)
|
||||
|
||||
return True, message, str(backup_path)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Ошибка создания бекапа: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("error", error_msg)
|
||||
|
||||
return False, error_msg, None
|
||||
|
||||
async def restore_backup(
|
||||
self,
|
||||
backup_file_path: str,
|
||||
clear_existing: bool = False
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
logger.info(f"🔄 Начинаем восстановление из {backup_file_path}")
|
||||
|
||||
backup_path = Path(backup_file_path)
|
||||
if not backup_path.exists():
|
||||
return False, f"❌ Файл бекапа не найден: {backup_file_path}"
|
||||
|
||||
if backup_path.suffix == '.gz':
|
||||
async with aiofiles.open(backup_path, 'rb') as f:
|
||||
compressed_data = await f.read()
|
||||
uncompressed_data = gzip.decompress(compressed_data).decode('utf-8')
|
||||
backup_structure = json_lib.loads(uncompressed_data)
|
||||
else:
|
||||
async with aiofiles.open(backup_path, 'r', encoding='utf-8') as f:
|
||||
file_content = await f.read()
|
||||
backup_structure = json_lib.loads(file_content)
|
||||
|
||||
metadata = backup_structure.get("metadata", {})
|
||||
backup_data = backup_structure.get("data", {})
|
||||
|
||||
if not backup_data:
|
||||
return False, "❌ Файл бекапа не содержит данных"
|
||||
|
||||
logger.info(f"📊 Загружен бекап от {metadata.get('timestamp')}")
|
||||
logger.info(f"📈 Содержит {metadata.get('total_records', 0)} записей")
|
||||
|
||||
restored_records = 0
|
||||
restored_tables = 0
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
if clear_existing:
|
||||
logger.warning("🗑️ Очищаем существующие данные...")
|
||||
await self._clear_database_tables(db)
|
||||
|
||||
for table_name, records in backup_data.items():
|
||||
if not records:
|
||||
continue
|
||||
|
||||
model = None
|
||||
for m in self.backup_models:
|
||||
if m.__tablename__ == table_name:
|
||||
model = m
|
||||
break
|
||||
|
||||
if not model:
|
||||
logger.warning(f"⚠️ Модель для таблицы {table_name} не найдена, пропускаем")
|
||||
continue
|
||||
|
||||
logger.info(f"📥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
|
||||
|
||||
for record_data in records:
|
||||
try:
|
||||
processed_data = {}
|
||||
for key, value in record_data.items():
|
||||
if value is None:
|
||||
processed_data[key] = None
|
||||
continue
|
||||
|
||||
column = getattr(model.__table__.columns, key, None)
|
||||
if column is None:
|
||||
continue
|
||||
|
||||
column_type_str = str(column.type).upper()
|
||||
if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str):
|
||||
try:
|
||||
if 'T' in value:
|
||||
processed_data[key] = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
else:
|
||||
processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Не удалось парсить дату {value} для поля {key}: {e}")
|
||||
processed_data[key] = datetime.utcnow()
|
||||
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
|
||||
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif ('INTEGER' in column_type_str or 'INT' in column_type_str) and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = int(value)
|
||||
except ValueError:
|
||||
processed_data[key] = 0
|
||||
elif ('FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str) and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = float(value)
|
||||
except ValueError:
|
||||
processed_data[key] = 0.0
|
||||
elif 'JSON' in column_type_str and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = json_lib.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
processed_data[key] = value
|
||||
else:
|
||||
processed_data[key] = value
|
||||
|
||||
# Проверяем существует ли запись с таким ID
|
||||
primary_key_col = None
|
||||
for col in model.__table__.columns:
|
||||
if col.primary_key:
|
||||
primary_key_col = col.name
|
||||
break
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
# Проверяем существование записи
|
||||
existing_record = await db.execute(
|
||||
select(model).where(
|
||||
getattr(model, primary_key_col) == processed_data[primary_key_col]
|
||||
)
|
||||
)
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Обновляем существующую запись
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col: # Не обновляем primary key
|
||||
setattr(existing, key, value)
|
||||
logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}")
|
||||
else:
|
||||
# Создаем новую запись
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
else:
|
||||
# Если нет primary key или он не в данных, просто добавляем
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
|
||||
restored_records += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
|
||||
continue
|
||||
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
await db.commit()
|
||||
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Ошибка при восстановлении: {e}")
|
||||
raise e
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
message = (f"✅ Восстановление завершено!\n"
|
||||
f"📊 Таблиц: {restored_tables}\n"
|
||||
f"📈 Записей: {restored_records:,}\n"
|
||||
f"📅 Дата бекапа: {metadata.get('timestamp', 'неизвестно')}")
|
||||
|
||||
logger.info(message)
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("restore_success", message)
|
||||
|
||||
return True, message
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Ошибка восстановления: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("restore_error", error_msg)
|
||||
|
||||
return False, error_msg
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession):
|
||||
tables_order = [
|
||||
"subscription_servers", "sent_notifications", "broadcast_history",
|
||||
"subscription_conversions", "referral_earnings", "promocode_uses",
|
||||
"transactions", "yookassa_payments", "cryptobot_payments",
|
||||
"subscriptions", "users", "promocodes", "server_squads",
|
||||
"service_rules", "system_settings", "monitoring_logs", "user_messages"
|
||||
]
|
||||
|
||||
for table_name in tables_order:
|
||||
try:
|
||||
await db.execute(text(f"DELETE FROM {table_name}"))
|
||||
logger.info(f"🗑️ Очищена таблица {table_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {e}")
|
||||
|
||||
async def get_backup_list(self) -> List[Dict[str, Any]]:
|
||||
backups = []
|
||||
|
||||
try:
|
||||
for backup_file in sorted(self.backup_dir.glob("backup_*.json*"), reverse=True):
|
||||
try:
|
||||
if backup_file.suffix == '.gz':
|
||||
with gzip.open(backup_file, 'rt', encoding='utf-8') as f:
|
||||
backup_structure = json_lib.load(f)
|
||||
else:
|
||||
with open(backup_file, 'r', encoding='utf-8') as f:
|
||||
backup_structure = json_lib.load(f)
|
||||
|
||||
metadata = backup_structure.get("metadata", {})
|
||||
file_stats = backup_file.stat()
|
||||
|
||||
backup_info = {
|
||||
"filename": backup_file.name,
|
||||
"filepath": str(backup_file),
|
||||
"timestamp": metadata.get("timestamp"),
|
||||
"tables_count": metadata.get("tables_count", 0),
|
||||
"total_records": metadata.get("total_records", 0),
|
||||
"compressed": metadata.get("compressed", False),
|
||||
"file_size_bytes": file_stats.st_size,
|
||||
"file_size_mb": round(file_stats.st_size / 1024 / 1024, 2),
|
||||
"created_by": metadata.get("created_by"),
|
||||
"database_type": metadata.get("database_type", "unknown")
|
||||
}
|
||||
|
||||
backups.append(backup_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка чтения метаданных {backup_file}: {e}")
|
||||
file_stats = backup_file.stat()
|
||||
backups.append({
|
||||
"filename": backup_file.name,
|
||||
"filepath": str(backup_file),
|
||||
"timestamp": datetime.fromtimestamp(file_stats.st_mtime).isoformat(),
|
||||
"tables_count": "?",
|
||||
"total_records": "?",
|
||||
"compressed": backup_file.suffix == '.gz',
|
||||
"file_size_bytes": file_stats.st_size,
|
||||
"file_size_mb": round(file_stats.st_size / 1024 / 1024, 2),
|
||||
"created_by": None,
|
||||
"database_type": "unknown",
|
||||
"error": f"Ошибка чтения: {str(e)}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка бекапов: {e}")
|
||||
|
||||
return backups
|
||||
|
||||
async def delete_backup(self, backup_filename: str) -> Tuple[bool, str]:
|
||||
try:
|
||||
backup_path = self.backup_dir / backup_filename
|
||||
|
||||
if not backup_path.exists():
|
||||
return False, f"❌ Файл бекапа не найден: {backup_filename}"
|
||||
|
||||
backup_path.unlink()
|
||||
message = f"✅ Бекап {backup_filename} удален"
|
||||
logger.info(message)
|
||||
|
||||
return True, message
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Ошибка удаления бекапа: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
async def _cleanup_old_backups(self):
|
||||
try:
|
||||
backups = await self.get_backup_list()
|
||||
|
||||
if len(backups) > self._settings.max_backups_keep:
|
||||
backups.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
||||
|
||||
for backup in backups[self._settings.max_backups_keep:]:
|
||||
try:
|
||||
await self.delete_backup(backup["filename"])
|
||||
logger.info(f"🗑️ Удален старый бекап: {backup['filename']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления старого бекапа {backup['filename']}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки старых бекапов: {e}")
|
||||
|
||||
async def get_backup_settings(self) -> BackupSettings:
|
||||
return self._settings
|
||||
|
||||
async def update_backup_settings(self, **kwargs) -> bool:
|
||||
try:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self._settings, key):
|
||||
setattr(self._settings, key, value)
|
||||
|
||||
if self._settings.auto_backup_enabled:
|
||||
await self.start_auto_backup()
|
||||
else:
|
||||
await self.stop_auto_backup()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления настроек бекапов: {e}")
|
||||
return False
|
||||
|
||||
async def start_auto_backup(self):
|
||||
if self._auto_backup_task and not self._auto_backup_task.done():
|
||||
self._auto_backup_task.cancel()
|
||||
|
||||
if self._settings.auto_backup_enabled:
|
||||
self._auto_backup_task = asyncio.create_task(self._auto_backup_loop())
|
||||
logger.info(f"🔄 Автобекапы включены, интервал: {self._settings.backup_interval_hours}ч")
|
||||
|
||||
async def stop_auto_backup(self):
|
||||
if self._auto_backup_task and not self._auto_backup_task.done():
|
||||
self._auto_backup_task.cancel()
|
||||
logger.info("⏹️ Автобекапы остановлены")
|
||||
|
||||
async def _auto_backup_loop(self):
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self._settings.backup_interval_hours * 3600)
|
||||
|
||||
logger.info("🔄 Запуск автоматического бекапа...")
|
||||
success, message, _ = await self.create_backup()
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Автобекап завершен: {message}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка автобекапа: {message}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле автобекапов: {e}")
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
async def _send_backup_notification(
|
||||
self,
|
||||
event_type: str,
|
||||
message: str,
|
||||
file_path: str = None
|
||||
):
|
||||
try:
|
||||
if not settings.is_admin_notifications_enabled():
|
||||
return
|
||||
|
||||
icons = {
|
||||
"success": "✅",
|
||||
"error": "❌",
|
||||
"restore_success": "📥",
|
||||
"restore_error": "❌"
|
||||
}
|
||||
|
||||
icon = icons.get(event_type, "ℹ️")
|
||||
notification_text = f"{icon} <b>СИСТЕМА БЕКАПОВ</b>\n\n{message}"
|
||||
|
||||
if file_path:
|
||||
notification_text += f"\n📁 <code>{Path(file_path).name}</code>"
|
||||
|
||||
notification_text += f"\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
admin_service = AdminNotificationService(self.bot)
|
||||
await admin_service._send_message(notification_text)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления через AdminNotificationService: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о бекапе: {e}")
|
||||
|
||||
|
||||
backup_service = BackupService()
|
||||
@@ -17,6 +17,10 @@ from app.database.crud.user import (
|
||||
get_user_by_id, get_inactive_users, delete_user,
|
||||
subtract_user_balance
|
||||
)
|
||||
from app.database.crud.notification import (
|
||||
notification_sent,
|
||||
record_notification,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -186,31 +190,30 @@ class MonitoringService:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}"
|
||||
|
||||
user_key = f"user_{user.telegram_id}_today"
|
||||
|
||||
if (notification_key in self._notified_users or
|
||||
|
||||
if (await notification_sent(db, user.id, subscription.id, "expiring", days) or
|
||||
user_key in all_processed_users):
|
||||
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
|
||||
continue
|
||||
|
||||
|
||||
should_send = True
|
||||
for other_days in warning_days:
|
||||
if other_days < days:
|
||||
if other_days < days:
|
||||
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
|
||||
if any(s.user_id == user.id for s in other_subs):
|
||||
should_send = False
|
||||
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
|
||||
break
|
||||
|
||||
|
||||
if not should_send:
|
||||
continue
|
||||
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
await record_notification(db, user.id, subscription.id, "expiring", days)
|
||||
all_processed_users.add(user_key)
|
||||
sent_count += 1
|
||||
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
|
||||
@@ -249,15 +252,14 @@ class MonitoringService:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
|
||||
if notification_key in self._notified_users:
|
||||
continue
|
||||
|
||||
|
||||
if await notification_sent(db, user.id, subscription.id, "trial_2h"):
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_trial_ending_notification(user, subscription)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
await record_notification(db, user.id, subscription.id, "trial_2h")
|
||||
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
|
||||
|
||||
if trial_expiring:
|
||||
@@ -453,13 +455,13 @@ class MonitoringService:
|
||||
Ваша тестовая подписка истекает через 2 часа.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку со скидкой!
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
• Скорость до 1ГБит/сек
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
@@ -55,7 +55,7 @@ async def process_referral_registration(
|
||||
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
|
||||
f"💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} "
|
||||
f"вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
|
||||
f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
|
||||
# f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
|
||||
)
|
||||
await send_referral_notification(bot, new_user.telegram_id, referral_notification)
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ class AdminStates(StatesGroup):
|
||||
creating_server_name = State()
|
||||
creating_server_price = State()
|
||||
creating_server_country = State()
|
||||
|
||||
editing_welcome_text = State()
|
||||
|
||||
|
||||
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from pathlib import Path
|
||||
from aiogram.types import Message, FSInputFile, InputMediaPhoto
|
||||
|
||||
from app.config import settings
|
||||
|
||||
LOGO_PATH = Path(settings.LOGO_FILE)
|
||||
|
||||
|
||||
def is_qr_message(message: Message) -> bool:
|
||||
return bool(message.caption and message.caption.startswith("\U0001F517 Ваша реферальная ссылка"))
|
||||
|
||||
|
||||
_original_answer = Message.answer
|
||||
_original_edit_text = Message.edit_text
|
||||
|
||||
|
||||
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
if LOGO_PATH.exists():
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
|
||||
|
||||
async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
if self.photo:
|
||||
media = self.photo[-1].file_id
|
||||
if is_qr_message(self) and LOGO_PATH.exists():
|
||||
media = FSInputFile(LOGO_PATH)
|
||||
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)
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
|
||||
|
||||
def patch_message_methods():
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
return
|
||||
Message.answer = _answer_with_photo
|
||||
Message.edit_text = _edit_with_photo
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import FSInputFile, InputMediaPhoto
|
||||
|
||||
from app.config import settings
|
||||
from .message_patch import LOGO_PATH, is_qr_message
|
||||
|
||||
|
||||
def _resolve_media(message: types.Message):
|
||||
if message.photo and not is_qr_message(message):
|
||||
return message.photo[-1].file_id
|
||||
return FSInputFile(LOGO_PATH)
|
||||
|
||||
|
||||
async def edit_or_answer_photo(
|
||||
callback: types.CallbackQuery,
|
||||
caption: str,
|
||||
keyboard: types.InlineKeyboardMarkup,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> None:
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
return
|
||||
|
||||
media = _resolve_media(callback.message)
|
||||
try:
|
||||
await callback.message.edit_media(
|
||||
InputMediaPhoto(media=media, caption=caption, parse_mode=parse_mode),
|
||||
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,
|
||||
)
|
||||
@@ -67,6 +67,8 @@ services:
|
||||
# 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"
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.services.version_service import version_service
|
||||
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
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -89,6 +90,20 @@ async def main():
|
||||
logger.info(f"📦 Текущая версия: {version_service.current_version}")
|
||||
|
||||
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
|
||||
|
||||
logger.info("🗄️ Инициализация сервиса бекапов...")
|
||||
try:
|
||||
backup_service.bot = bot
|
||||
|
||||
# Запускаем автобекапы если они включены
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
if settings_obj.auto_backup_enabled:
|
||||
await backup_service.start_auto_backup()
|
||||
logger.info("✅ Автобекапы запущены")
|
||||
|
||||
logger.info("✅ Сервис бекапов инициализирован")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}")
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
@@ -221,6 +236,12 @@ async def main():
|
||||
await version_check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса бекапов...")
|
||||
try:
|
||||
await backup_service.stop_auto_backup()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки сервиса бекапов: {e}")
|
||||
|
||||
if polling_task and not polling_task.done():
|
||||
logger.info("ℹ️ Остановка polling...")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add sent notifications table"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '8fd1e338eb45'
|
||||
down_revision: Union[str, None] = '3d9b35c6bd8f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
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'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('sent_notifications')
|
||||
@@ -0,0 +1,24 @@
|
||||
"""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'])
|
||||
@@ -29,3 +29,5 @@ qrcode[pil]==7.4.2
|
||||
|
||||
# Для работы с версиями
|
||||
packaging==23.2
|
||||
|
||||
aiofiles==23.2.1
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Reference in New Issue
Block a user