Compare commits

...

53 Commits

Author SHA1 Message Date
Egor e734763eaf Update Dockerfile 2025-09-11 05:28:23 +03:00
Egor cb98efc47e Update docker-registry.yml 2025-09-11 05:28:07 +03:00
Egor dbd3d87779 Update docker-hub.yml 2025-09-11 05:27:50 +03:00
Egor 533ae6dd80 Update admin.py 2025-09-11 05:27:10 +03:00
Egor ca62321b42 Merge pull request #52 from Fr1ngg/dev
Редактируемое приветственное сообщение
2025-09-11 05:16:41 +03:00
Egor cd3d2c3731 Update README.md 2025-09-11 05:14:22 +03:00
Egor 8b8d29a75d Update start.py 2025-09-11 05:09:30 +03:00
Egor 3f97757ec0 Update welcome_text.py 2025-09-11 05:08:33 +03:00
Egor d5f3299bd2 Update welcome_text.py 2025-09-11 05:07:52 +03:00
Egor 0b99965731 Update universal_migration.py 2025-09-11 05:04:08 +03:00
Egor 8f679832fb Update welcome_text.py 2025-09-11 04:57:49 +03:00
Egor 46564a8e91 Update welcome_text.py 2025-09-11 04:55:08 +03:00
Egor 09b0dbb60c Update welcome_text.py 2025-09-11 04:54:22 +03:00
Egor 28d53e7e3f Update welcome_text.py 2025-09-11 04:49:34 +03:00
Egor 43de39f4ec Update admin.py 2025-09-11 04:45:40 +03:00
Egor 49df0100c9 Update welcome_text.py 2025-09-11 04:43:51 +03:00
Egor c020f33c86 Update welcome_text.py 2025-09-11 04:33:08 +03:00
Egor 813daef2b1 Update start.py 2025-09-11 04:27:27 +03:00
Egor 13d4e934ec Update welcome_text.py 2025-09-11 04:25:07 +03:00
Egor 7721464611 Update admin.py 2025-09-11 04:18:18 +03:00
Egor 9e42c3e7d1 Update welcome_text.py 2025-09-11 04:17:17 +03:00
Egor badbc150ab Update welcome_text.py 2025-09-11 04:16:05 +03:00
Egor 5d57c88e4b Update bot.py 2025-09-11 04:06:13 +03:00
Egor ec18f82d26 Update start.py 2025-09-11 04:04:08 +03:00
Egor 846e5e6bf0 Update states.py 2025-09-11 04:02:12 +03:00
Egor 16b6df4d05 Update admin.py 2025-09-11 04:01:31 +03:00
Egor 9e45bd6d75 Update models.py 2025-09-11 04:00:04 +03:00
Egor d01436bf14 Create welcome_text.py 2025-09-11 03:59:24 +03:00
Egor f50e170f8d Create welcome_text.py 2025-09-11 03:58:34 +03:00
Egor 2dd76d8bd2 Update README.md 2025-09-11 03:39:30 +03:00
Egor b9956391f0 Update README.md 2025-09-11 03:37:44 +03:00
Egor fdfd0e2355 Merge pull request #51 from Fr1ngg/dev
Бекапы/Восстановление
2025-09-11 03:32:44 +03:00
Egor 548dd2fdc6 Update Dockerfile 2025-09-11 03:31:44 +03:00
Egor cdcdd7abdc Update README.md 2025-09-11 03:31:18 +03:00
Egor d5c8795e75 Delete app/data/2 2025-09-11 03:25:20 +03:00
Egor ceb0ac7538 Create 2 2025-09-11 03:25:04 +03:00
Egor 305026c37d Update Dockerfile 2025-09-11 03:14:30 +03:00
Egor f95367d12e Update .env.example 2025-09-11 03:09:02 +03:00
Egor ac962c43e8 Update backup_service.py 2025-09-11 03:03:25 +03:00
Egor 26efc70042 Update backup.py 2025-09-11 03:02:48 +03:00
Egor 7f431e1485 Update backup_service.py 2025-09-11 02:32:44 +03:00
Egor 1292046646 Update backup_service.py 2025-09-11 02:20:32 +03:00
Egor d54b455007 Update backup_service.py 2025-09-11 02:09:45 +03:00
Egor ca55d8942e Update backup.py 2025-09-11 01:59:37 +03:00
Egor f00fdcb7c8 Update backup.py 2025-09-11 01:55:56 +03:00
Egor 5443fdf064 Update requirements.txt 2025-09-10 22:57:35 +03:00
Egor c33e49661c Update config.py 2025-09-10 22:55:50 +03:00
Egor 92bd0c362e Update bot.py 2025-09-10 22:55:22 +03:00
Egor fe8cafdd99 Update main.py 2025-09-10 22:54:23 +03:00
Egor f0f8c4e4cf Update admin.py 2025-09-10 22:47:12 +03:00
Egor f8e2e23082 Create backup.py 2025-09-10 22:45:42 +03:00
Egor b915c65c41 Create backup_service.py 2025-09-10 22:42:35 +03:00
Egor 02106bd64b Update remnawave_api.py 2025-09-10 01:13:00 +03:00
19 changed files with 1986 additions and 102 deletions
+20 -10
View File
@@ -221,6 +221,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
@@ -229,13 +249,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
+3 -3
View File
@@ -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.6-$(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.6-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.6-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
+3 -3
View File
@@ -52,13 +52,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.6"
VERSION="v2.2.7"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.6-dev-$(git rev-parse --short HEAD)"
VERSION="v2.2.7-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.6-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
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.11-slim
ARG VERSION="v2.2.6"
ARG VERSION="v2.2.7"
ARG BUILD_DATE
ARG VCS_REF
+105 -33
View File
@@ -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,12 +380,26 @@ 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 =====
# Режим работы кнопки "Подключиться"
@@ -389,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
@@ -397,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>
@@ -436,6 +490,7 @@ VERSION_CHECK_INTERVAL_HOURS=1
- ⭐ Telegram Stars
- 💳 Tribute
- 💳 YooKassa
- 💰 CryptoBot
- 🎁 Реферальные бонусы
- Детальная история транзакций
@@ -490,6 +545,7 @@ VERSION_CHECK_INTERVAL_HOURS=1
- 🔔 Автоуведомления о продлении
- 💬 Система поддержки с HTML разметкой
- 📝 Настройка правил сервиса
- Настраиваемое приветственное сообщение с предложением активации триала
📨 **Уведомления в закрытый канал**
- 🎯 Активация триала
@@ -497,6 +553,14 @@ VERSION_CHECK_INTERVAL_HOURS=1
- 🔄 Конверсия из триала в платную
- ⏰ Продление подписки
- 💰 Пополнение баланса
- ♻️ Выход обновлений бота
- 🚧 Потеря соелинения с апи Remnawave
- 🗄️ **Бекапы/Восстановление бд**
🗄️ **Бекапы/Восстановление**
- Ручной запуск бекапа
- Восстановление бд
- Включение/Отключение автобекапов
</td>
</tr>
@@ -762,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 # Правила
@@ -786,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 # Рефералы
@@ -795,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 # Рефералы
@@ -933,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
+5 -1
View File
@@ -25,10 +25,12 @@ 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
logger = logging.getLogger(__name__)
@@ -104,6 +106,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)
+8
View File
@@ -156,6 +156,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
+109
View File
@@ -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 не указан)'
}
+12
View File
@@ -660,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")
+87 -17
View File
@@ -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}")
+70 -9
View File
@@ -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:
+647
View File
@@ -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
)
+283
View 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>&lt;b&gt;жирный текст&lt;/b&gt;</code> → <b>жирный текст</b>
• <code>&lt;i&gt;курсив&lt;/i&gt;</code> → <i>курсив</i>
• <code>&lt;u&gt;подчеркнутый&lt;/u&gt;</code> → <u>подчеркнутый</u>
• <code>&lt;s&gt;зачеркнутый&lt;/s&gt;</code> → <s>зачеркнутый</s>
• <code>&lt;code&gt;моноширинный&lt;/code&gt;</code> → <code>моноширинный</code>
• <code>&lt;pre&gt;блок кода&lt;/pre&gt;</code> → многострочный код
• <code>&lt;a href="URL"&gt;ссылка&lt;/a&gt;</code> → ссылка
⚠️ <b>ВНИМАНИЕ:</b> Используйте ТОЛЬКО указанные выше теги!
Любые другие HTML-теги не поддерживаются и будут отображаться как обычный текст.
❌ <b>НЕ используйте:</b> &lt;div&gt;, &lt;span&gt;, &lt;p&gt;, &lt;br&gt;, &lt;h1&gt;-&lt;h6&gt;, &lt;img&gt; и другие 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
)
+9 -24
View File
@@ -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)
@@ -499,18 +499,10 @@ async def complete_registration_from_callback(
await state.clear()
from app.database.crud.welcome_text import get_welcome_text_for_user
user_name = callback.from_user.first_name or callback.from_user.username or "друг"
offer_text = (
f"<b>Привет, {user.full_name}!</b>\n\n"
f"🎁 <b>{settings.TRIAL_DURATION_DAYS} дней VPN бесплатно!</b>\n"
"Подключайтесь за минуту и забудьте о блокировках.\n\n"
"✅ До <b>1 Гбит/с</b> скорость\n"
"✅ Умный VPN — можно не отключать для большинства российских сервисов\n"
"✅ Современные протоколы — максимум защиты и анонимности\n\n"
f"👉 Всего <b>{int(settings.PRICE_30_DAYS/100)}₽/мес за 1 устройство</b>\n\n"
"👇 Жмите кнопку и подключайтесь!"
)
offer_text = await get_welcome_text_for_user(db, user_name)
try:
await callback.message.answer(
@@ -527,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)
@@ -638,17 +630,9 @@ async def complete_registration(
await state.clear()
user_name = message.from_user.first_name or message.from_user.username or "друг"
offer_text = (
f"Привет, {user_name}!\n\n"
f"Подключите VPN бесплатно! Дарим вам {settings.TRIAL_DURATION_DAYS} дней!\n\n"
"Наши преимущества:\n"
" • Высокая скорость соединения — до 1гб/с\n"
" • Умный VPN — можно не отключать для большинства российских сервисов\n"
" • Самые современные протоколы — высокая защита от блокировки\n\n"
"Стоимость 100₽/мес за 1 устройство\n\n"
"👇Жмите на кнопку👇 чтобы подключить в 1 клик"
)
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, message.from_user)
try:
await message.answer(
@@ -807,3 +791,4 @@ def register_handlers(dp: Dispatcher):
logger.info("✅ Зарегистрирован handle_potential_referral_code")
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
+23 -1
View File
@@ -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")
]
])
+574
View File
@@ -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()
+4
View File
@@ -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):
+21
View File
@@ -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...")
+2
View File
@@ -29,3 +29,5 @@ qrcode[pil]==7.4.2
# Для работы с версиями
packaging==23.2
aiofiles==23.2.1