Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8e664aad8 | |||
| b365394ff4 | |||
| e53549d7cc | |||
| 743e99af85 | |||
| 9616fe8d44 | |||
| 4ae6361599 | |||
| 89c12e6ba9 | |||
| 5d6bc82d8a | |||
| a4f3fe5a49 | |||
| 3a58a3490a | |||
| e2408702ea | |||
| 3f1f27e5aa | |||
| bbd0ec2edd | |||
| eb22b19d98 | |||
| 45b9d44fd0 | |||
| a92003f0f5 | |||
| 4ac9387ef2 | |||
| a24b5e8145 | |||
| 7244e3978b | |||
| 41cabf5e0e | |||
| 338bc03cd6 | |||
| 985260144e | |||
| b487925fbf | |||
| 07a89443b3 | |||
| 54ebb2d96d | |||
| 34a7cd1f6c | |||
| 455b8fc709 | |||
| 591b422302 | |||
| fc73c34a03 | |||
| cc1c6e3088 | |||
| 966f7124c4 | |||
| 1cebec5af4 | |||
| d31b5097ff | |||
| 21a8fe4025 | |||
| 6a4adcfe0c | |||
| c9acd46cd3 | |||
| 6018113a3d | |||
| fe0825957a | |||
| d0cef51e35 | |||
| 2aa0d11f4c | |||
| b8a1aa3f3c | |||
| 80e95bc77b | |||
| 87af0312cb | |||
| e988c9aa14 | |||
| cdee98f690 | |||
| 268c619ad6 | |||
| 352434ae35 | |||
| b1e39bae32 | |||
| 44a2c929c4 | |||
| 42a9865fac | |||
| c730863162 | |||
| b58db19127 | |||
| 2820c2d942 | |||
| 0be172c675 | |||
| b26fd77190 | |||
| 9317a1703a | |||
| f166279507 | |||
| 60216519af | |||
| ed83d31b22 | |||
| cf6e2955c7 | |||
| 4e3a183e2b | |||
| 0879855d3f | |||
| 2358552961 | |||
| 35b461b60c | |||
| 1b5cfc3920 | |||
| 4a179ccc1e | |||
| 4921578af3 | |||
| 55fb1f2044 | |||
| ad38aae7b0 | |||
| 7e0840ef09 | |||
| 03fcb5f2f7 | |||
| 0c4ea86e03 | |||
| 209bb9763e | |||
| 187de6807e | |||
| b8c9302018 | |||
| a86fccffc0 | |||
| a448adec93 | |||
| d45ff62a57 | |||
| 3c50fc6a71 | |||
| 292a120f74 | |||
| a7868787a4 | |||
| 5275f4fbeb | |||
| ba25ec7ad0 | |||
| 301b4124ef | |||
| a936a97e5e | |||
| c390d18399 | |||
| 43df9bff48 | |||
| c15a9ea7cd | |||
| 271742b306 | |||
| ebdadd81c0 | |||
| 1364855303 | |||
| b5d862b231 | |||
| 048a8adaa1 | |||
| 4b7b999d43 | |||
| d02fa83af5 | |||
| 89045095b1 | |||
| 0de721e67d | |||
| c7c171a1d3 | |||
| a2500148fd | |||
| 99b8a3726b | |||
| 8b426e0f6c | |||
| caca561997 | |||
| 385bd78021 | |||
| 8ca127e93f | |||
| ac9b8aaf68 | |||
| 4939f56f1b | |||
| 659e950722 | |||
| 5186ae4334 | |||
| 64cd38e2c3 | |||
| cc29c887c5 | |||
| 7fd7dd7cc8 | |||
| 9ac3088b72 | |||
| 9745885f1b | |||
| 818189e8e0 | |||
| 2ccaaebfc9 | |||
| 82e75f2170 | |||
| 1b265fd95b | |||
| 7a8905e0bc | |||
| 6d8ba6d30c | |||
| 74544c9a9e | |||
| 3314bb7694 | |||
| c920bac33e | |||
| e6881ef617 | |||
| 94a7bb43f6 | |||
| 97841f0cbe | |||
| de392bd4de | |||
| f2a3e5f2fc | |||
| 20a2566fe6 | |||
| 96bd411839 | |||
| 773196fd84 | |||
| 26dc8b4963 | |||
| 06a9202c7c | |||
| 634786917d | |||
| 4ea839c30a | |||
| 10f6ea382b | |||
| 929414e31f | |||
| cd87a3e773 | |||
| 7a3168dcfd | |||
| 888f8fb01a | |||
| 6f06ec5aa0 | |||
| e10cb3f2bb | |||
| 2edd3cb5ab | |||
| d6d3dc83f5 |
+40
-4
@@ -12,6 +12,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_SUB_REQUIRED=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
DATABASE_MODE=auto
|
||||
@@ -34,9 +39,25 @@ REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=your_secret_key_here
|
||||
REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
REMNAWAVE_PASSWORD=
|
||||
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=
|
||||
|
||||
# Шаблон описания пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры:
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
@@ -183,7 +204,7 @@ 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_WEBHOOK_PORT=8081
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
@@ -194,15 +215,24 @@ CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
|
||||
HIDE_SUBSCRIPTION_LINK=false
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
|
||||
# Пропустить принятие правил использования бота
|
||||
SKIP_RULES_ACCEPT=false
|
||||
# Пропустить запрос реферального кода
|
||||
SKIP_REFERRAL_CODE=false
|
||||
|
||||
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
|
||||
MONITORING_INTERVAL=60
|
||||
INACTIVE_USER_DELETE_MONTHS=3
|
||||
@@ -239,6 +269,12 @@ BACKUP_COMPRESSION=true
|
||||
BACKUP_INCLUDE_LOGS=false
|
||||
BACKUP_LOCATION=/app/data/backups
|
||||
|
||||
# Отправка бэкапов в телеграм
|
||||
BACKUP_SEND_ENABLED=true
|
||||
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
|
||||
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Custom issue template
|
||||
about: Describe this issue template's purpose here.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -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.7-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.3-$(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.7-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.3-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.7-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.3-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -52,13 +52,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.2.7"
|
||||
VERSION="v2.3.3"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.2.7-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.3-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.2.7-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.3-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
# 🤝 Contributing to Remnawave Bedolaga Bot
|
||||
|
||||
Спасибо за интерес к развитию проекта! Этот документ содержит правила и рекомендации для контрибьюторов.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Кодекс поведения](#-кодекс-поведения)
|
||||
- [Как помочь проекту](#-как-помочь-проекту)
|
||||
- [Настройка среды разработки](#-настройка-среды-разработки)
|
||||
- [Стандарты кода](#-стандарты-кода)
|
||||
- [Работа с Git](#-работа-с-git)
|
||||
- [Тестирование](#-тестирование)
|
||||
- [Процесс ревью](#-процесс-ревью)
|
||||
- [Документация](#-документация)
|
||||
|
||||
## 📜 Кодекс поведения
|
||||
|
||||
### Основные принципы
|
||||
|
||||
- **Уважение** - относитесь к участникам проекта с уважением
|
||||
- **Конструктивность** - предлагайте решения, а не только критику
|
||||
- **Открытость** - будьте открыты к обратной связи и новым идеям
|
||||
- **Профессионализм** - поддерживайте высокий уровень обсуждений
|
||||
|
||||
### Недопустимое поведение
|
||||
|
||||
- Оскорбления и личные атаки
|
||||
- Спам и офф-топик сообщения
|
||||
- Публикация приватной информации
|
||||
- Любые формы дискриминации
|
||||
|
||||
## 🚀 Как помочь проекту
|
||||
|
||||
### 🐛 Сообщения о багах
|
||||
|
||||
Перед созданием issue проверьте:
|
||||
- [ ] Аналогичная проблема не была описана ранее
|
||||
- [ ] Вы используете актуальную версию бота
|
||||
- [ ] Проблема воспроизводится стабильно
|
||||
|
||||
**Шаблон для багрепорта:**
|
||||
|
||||
```markdown
|
||||
## 🐛 Описание бага
|
||||
Краткое описание проблемы
|
||||
|
||||
## 🔄 Шаги воспроизведения
|
||||
1. Перейти к '...'
|
||||
2. Нажать на '...'
|
||||
3. Увидеть ошибку
|
||||
|
||||
## ✅ Ожидаемое поведение
|
||||
Что должно было произойти
|
||||
|
||||
## ❌ Фактическое поведение
|
||||
Что произошло на самом деле
|
||||
|
||||
## 🌍 Окружение
|
||||
- Версия бота: [например, 1.0.0]
|
||||
- Python версия: [например, 3.11.7]
|
||||
- ОС: [например, Ubuntu 22.04]
|
||||
- Версия Docker: [если используется]
|
||||
|
||||
## 📋 Логи
|
||||
```
|
||||
Вставьте соответствующие логи
|
||||
```
|
||||
|
||||
## 📷 Скриншоты
|
||||
Если применимо, добавьте скриншоты
|
||||
```
|
||||
|
||||
### 💡 Предложения функций
|
||||
|
||||
Используйте лейбл `enhancement` и опишите:
|
||||
- **Проблему**, которую решает функция
|
||||
- **Предлагаемое решение**
|
||||
- **Альтернативы**, которые вы рассматривали
|
||||
- **Дополнительную информацию** и контекст
|
||||
|
||||
### 📝 Улучшение документации
|
||||
|
||||
- Исправление опечаток
|
||||
- Дополнение примеров
|
||||
- Перевод на другие языки
|
||||
- Улучшение структуры
|
||||
|
||||
## 🛠 Настройка среды разработки
|
||||
|
||||
### Требования
|
||||
|
||||
- Python 3.11+
|
||||
- Docker и Docker Compose
|
||||
- Git
|
||||
- PostgreSQL 15+ (опционально для локальной разработки)
|
||||
- Redis (опционально для локальной разработки)
|
||||
|
||||
### Установка
|
||||
|
||||
1. **Форкните и клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
```
|
||||
|
||||
2. **Создайте виртуальное окружение:**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# или
|
||||
venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. **Установите зависимости:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt # если есть dev зависимости
|
||||
```
|
||||
|
||||
4. **Настройте окружение:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Отредактируйте .env файл с вашими настройками
|
||||
```
|
||||
|
||||
5. **Запустите через Docker (рекомендуется):**
|
||||
```bash
|
||||
docker compose up -d postgres redis
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
bedolaga_bot/
|
||||
├── app/ # Основной код приложения
|
||||
│ ├── handlers/ # Обработчики сообщений
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ ├── database/ # Модели и CRUD операции
|
||||
│ ├── utils/ # Утилиты
|
||||
│ ├── middlewares/ # Middleware
|
||||
│ └── external/ # Внешние API
|
||||
├── migrations/ # Миграции БД (если нужны)
|
||||
├── tests/ # Тесты (создать при необходимости)
|
||||
├── docs/ # Документация
|
||||
└── requirements.txt # Зависимости
|
||||
```
|
||||
|
||||
## 🎨 Стандарты кода
|
||||
|
||||
### Python стиль
|
||||
|
||||
Мы следуем **PEP 8** с некоторыми исключениями:
|
||||
|
||||
```python
|
||||
# ✅ Хорошо
|
||||
async def get_user_subscription(user_id: int) -> Optional[Subscription]:
|
||||
"""Получает активную подписку пользователя."""
|
||||
async with get_session() as session:
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.where(Subscription.is_active == True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
# ❌ Плохо
|
||||
async def getUserSub(uid):
|
||||
session = get_session()
|
||||
sub = session.query(Subscription).filter(Subscription.user_id==uid,Subscription.is_active==True).first()
|
||||
return sub
|
||||
```
|
||||
|
||||
### Правила именования
|
||||
|
||||
- **Функции и переменные**: `snake_case`
|
||||
- **Классы**: `PascalCase`
|
||||
- **Константы**: `UPPER_CASE`
|
||||
- **Приватные методы**: `_leading_underscore`
|
||||
|
||||
### Типизация
|
||||
|
||||
Обязательно используйте type hints:
|
||||
|
||||
```python
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
async def create_subscription(
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
) -> Subscription:
|
||||
"""Создает новую подписку."""
|
||||
# implementation
|
||||
```
|
||||
|
||||
### Документация кода
|
||||
|
||||
```python
|
||||
async def calculate_subscription_price(
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
devices_count: int,
|
||||
servers_count: int
|
||||
) -> int:
|
||||
"""
|
||||
Рассчитывает стоимость подписки.
|
||||
|
||||
Args:
|
||||
period_days: Период подписки в днях
|
||||
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
|
||||
devices_count: Количество устройств
|
||||
servers_count: Количество серверов
|
||||
|
||||
Returns:
|
||||
Стоимость в копейках
|
||||
|
||||
Raises:
|
||||
ValueError: Если переданы некорректные параметры
|
||||
"""
|
||||
# implementation
|
||||
```
|
||||
|
||||
### Обработка ошибок
|
||||
|
||||
```python
|
||||
# ✅ Хорошо
|
||||
try:
|
||||
subscription = await subscription_service.create_subscription(user_id, data)
|
||||
await message.answer("✅ Подписка создана успешно!")
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"RemnaWave API error: {e}")
|
||||
await message.answer("❌ Ошибка при создании подписки. Попробуйте позже.")
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Validation error: {e}")
|
||||
await message.answer("❌ Некорректные данные для создания подписки.")
|
||||
|
||||
# ❌ Плохо
|
||||
try:
|
||||
subscription = await subscription_service.create_subscription(user_id, data)
|
||||
await message.answer("✅ Подписка создана успешно!")
|
||||
except:
|
||||
await message.answer("Ошибка")
|
||||
```
|
||||
|
||||
### Логирование
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Уровни логирования
|
||||
logger.debug("Детальная информация для отладки")
|
||||
logger.info("Общая информация о работе")
|
||||
logger.warning("Предупреждение о потенциальной проблеме")
|
||||
logger.error("Ошибка, которая не прерывает работу")
|
||||
logger.critical("Критическая ошибка")
|
||||
```
|
||||
|
||||
## 🔄 Работа с Git
|
||||
|
||||
### Ветки
|
||||
|
||||
- `main` - стабильная версия
|
||||
- `dev` - разработка новых функций
|
||||
|
||||
### Коммиты
|
||||
|
||||
Используйте [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```bash
|
||||
# Типы коммитов
|
||||
feat: добавить поддержку CryptoBot платежей
|
||||
fix: исправить ошибку с расчетом цены подписки
|
||||
docs: обновить документацию по настройке
|
||||
style: исправить форматирование кода
|
||||
refactor: рефакторинг сервиса платежей
|
||||
test: добавить тесты для subscription_service
|
||||
chore: обновить зависимости
|
||||
|
||||
# Примеры
|
||||
git commit -m "feat(payments): добавить поддержку YooKassa webhook"
|
||||
git commit -m "fix(subscription): исправить расчет стоимости устройств"
|
||||
git commit -m "docs(readme): обновить инструкцию по установке"
|
||||
```
|
||||
|
||||
### Pull Request процесс
|
||||
|
||||
1. **Создайте ветку** от `dev`:
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/new-payment-method
|
||||
```
|
||||
|
||||
2. **Разработайте функцию** с соблюдением стандартов
|
||||
|
||||
3. **Протестируйте** изменения локально
|
||||
|
||||
4. **Создайте Pull Request** с описанием:
|
||||
|
||||
```markdown
|
||||
## 📝 Описание
|
||||
Краткое описание изменений
|
||||
|
||||
## 🎯 Мотивация
|
||||
Почему эти изменения нужны?
|
||||
|
||||
## 🔧 Тип изменений
|
||||
- [ ] Bug fix (исправление)
|
||||
- [ ] New feature (новая функция)
|
||||
- [ ] Breaking change (ломающие изменения)
|
||||
- [ ] Documentation update (обновление документации)
|
||||
|
||||
## ✅ Чеклист
|
||||
- [ ] Код соответствует стандартам проекта
|
||||
- [ ] Добавлены/обновлены тесты
|
||||
- [ ] Документация обновлена
|
||||
- [ ] Проверена работа в Docker
|
||||
- [ ] Проверена совместимость с существующим API
|
||||
|
||||
## 🧪 Тестирование
|
||||
Как тестировались изменения:
|
||||
- [ ] Локальное тестирование
|
||||
- [ ] Тестирование с реальным Remnawave API
|
||||
- [ ] Тестирование платежных систем
|
||||
```
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### Локальное тестирование
|
||||
|
||||
```bash
|
||||
# Запуск с тестовой базой
|
||||
export DATABASE_URL="sqlite:///test.db"
|
||||
export BOT_TOKEN="test_token"
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Тестирование компонентов
|
||||
|
||||
```python
|
||||
# tests/test_subscription_service.py
|
||||
import pytest
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_price():
|
||||
price = await SubscriptionService.calculate_subscription_price(
|
||||
period_days=30,
|
||||
traffic_gb=100,
|
||||
devices_count=3,
|
||||
servers_count=1
|
||||
)
|
||||
assert price > 0
|
||||
assert isinstance(price, int)
|
||||
```
|
||||
|
||||
### Integration тесты
|
||||
|
||||
Тестируйте интеграцию с:
|
||||
- Remnawave API (с тестовыми данными)
|
||||
- Базой данных
|
||||
- Платежными системами (sandbox режим)
|
||||
|
||||
## 👀 Процесс ревью
|
||||
|
||||
### Требования для ревьюера
|
||||
|
||||
- Проверить соответствие стандартам кода
|
||||
- Убедиться в работоспособности
|
||||
- Проверить безопасность (особенно для платежных функций)
|
||||
- Оценить производительность
|
||||
- Проверить совместимость с существующим API
|
||||
|
||||
### Требования для автора
|
||||
|
||||
- Ответить на все комментарии
|
||||
- Исправить замечания
|
||||
- Обновить документацию при необходимости
|
||||
- Убедиться в прохождении всех проверок
|
||||
|
||||
## 📚 Документация
|
||||
|
||||
### Обновление документации
|
||||
|
||||
При добавлении новых функций обновляйте:
|
||||
|
||||
- `README.md` - если изменился API или конфигурация
|
||||
- Комментарии в коде
|
||||
- Примеры использования
|
||||
- Changelog (если есть)
|
||||
|
||||
### Стиль документации
|
||||
|
||||
- Используйте ясный и понятный язык
|
||||
- Добавляйте примеры кода
|
||||
- Указывайте возможные ошибки и их решения
|
||||
- Используйте эмодзи для улучшения читаемости
|
||||
|
||||
## 🏷 Лейблы Issues и PR
|
||||
|
||||
### Приоритет
|
||||
- `priority:high` - высокий приоритет
|
||||
- `priority:medium` - средний приоритет
|
||||
- `priority:low` - низкий приоритет
|
||||
|
||||
### Тип
|
||||
- `bug` - ошибка
|
||||
- `enhancement` - улучшение
|
||||
- `feature` - новая функция
|
||||
- `documentation` - документация
|
||||
- `question` - вопрос
|
||||
|
||||
### Область
|
||||
- `payments` - платежные системы
|
||||
- `api` - Remnawave API
|
||||
- `database` - база данных
|
||||
- `ui/ux` - интерфейс пользователя
|
||||
- `admin` - админ панель
|
||||
|
||||
## 🔐 Безопасность
|
||||
|
||||
### Сообщения о уязвимостях
|
||||
|
||||
Для сообщений о критических уязвимостях безопасности:
|
||||
- Свяжитесь с [@fringg](https://t.me/fringg) напрямую
|
||||
- Не создавайте публичные issues для уязвимостей
|
||||
- Дайте время на исправление перед публичным раскрытием
|
||||
|
||||
### Рекомендации по безопасности
|
||||
|
||||
- Никогда не коммитьте API ключи и пароли
|
||||
- Используйте переменные окружения для чувствительных данных
|
||||
- Валидируйте все пользовательские данные
|
||||
- Используйте HTTPS для всех внешних запросов
|
||||
|
||||
## 📞 Получение помощи
|
||||
|
||||
### Каналы связи
|
||||
|
||||
- **💬 Telegram Group:** [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi) - общие вопросы
|
||||
- **🐛 GitHub Issues:** Технические вопросы и баги
|
||||
- **📧 Прямой контакт:** [@fringg](https://t.me/fringg) - только критические вопросы
|
||||
|
||||
### Часто задаваемые вопросы
|
||||
|
||||
**Q: Как настроить локальную разработку без Docker?**
|
||||
A: Установите PostgreSQL и Redis локально, обновите DATABASE_URL и REDIS_URL в .env
|
||||
|
||||
**Q: Можно ли использовать SQLite для разработки?**
|
||||
A: Да, установите DATABASE_MODE=sqlite в .env
|
||||
|
||||
**Q: Как тестировать платежные системы?**
|
||||
A: Используйте тестовые/sandbox режимы платежных систем
|
||||
|
||||
**Q: Что делать, если тесты не проходят?**
|
||||
A: Проверьте конфигурацию .env и убедитесь, что все сервисы запущены
|
||||
|
||||
## 📋 Чеклист для контрибьюторов
|
||||
|
||||
Перед отправкой PR убедитесь:
|
||||
|
||||
- [ ] ✅ Код соответствует стандартам проекта
|
||||
- [ ] 📝 Добавлены комментарии и docstrings
|
||||
- [ ] 🧪 Функциональность протестирована
|
||||
- [ ] 📚 Документация обновлена
|
||||
- [ ] 🔒 Нет чувствительных данных в коде
|
||||
- [ ] 🐳 Изменения работают в Docker
|
||||
- [ ] 📋 PR создан с подробным описанием
|
||||
- [ ] 🏷 Проставлены соответствующие лейблы
|
||||
|
||||
## 🎉 Благодарности
|
||||
|
||||
Спасибо всем, кто вносит вклад в развитие проекта! Ваша помощь делает Bedolaga Bot лучше для всего сообщества.
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ by community**
|
||||
+2
-2
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG VERSION="v2.2.7"
|
||||
ARG VERSION="v2.3.3"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8081 8082
|
||||
EXPOSE 8081 8082 8083
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
|
||||
@@ -55,6 +55,17 @@
|
||||
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
|
||||
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомления в топики
|
||||
|
||||
### 📚 Поддерживаемые методы авторизации
|
||||
|
||||
Метод | Заголовок | Описание
|
||||
-- | -- | --
|
||||
API Key | X-Api-Key: your_api_key | Стандартный API ключ
|
||||
Bearer Token | Authorization: Bearer token | Классический Bearer token
|
||||
Basic Auth | X-Api-Key: Basic base64(user:pass) | Basic Authentication
|
||||
eGames Cookies | Cookies в формате key:value | Для панелей eGames
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
@@ -246,9 +257,17 @@ REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=your_secret_key_here
|
||||
REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
REMNAWAVE_PASSWORD=
|
||||
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
@@ -395,23 +414,35 @@ 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_WEBHOOK_PORT=8081
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
|
||||
HIDE_SUBSCRIPTION_LINK=false
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - открывает ссылку подписки напрямую (режим 4)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
|
||||
# Пропустить принятие правил использования бота
|
||||
SKIP_RULES_ACCEPT=false
|
||||
# Пропустить запрос реферального кода
|
||||
SKIP_REFERRAL_CODE=false
|
||||
|
||||
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
|
||||
MONITORING_INTERVAL=60
|
||||
INACTIVE_USER_DELETE_MONTHS=3
|
||||
@@ -448,6 +479,12 @@ BACKUP_COMPRESSION=true
|
||||
BACKUP_INCLUDE_LOGS=false
|
||||
BACKUP_LOCATION=/app/data/backups
|
||||
|
||||
# Отправка бэкапов в телеграм
|
||||
BACKUP_SEND_ENABLED=true
|
||||
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
|
||||
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
@@ -459,8 +496,9 @@ LOG_FILE=logs/bot.log
|
||||
|
||||
# ===== РАЗРАБОТКА =====
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_URL= # Укажите домен вебхука
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -485,6 +523,7 @@ WEBHOOK_PATH=/webhook
|
||||
- Настраиваемый триал-период
|
||||
- Уведомления об истечении
|
||||
- Плавный переход на платную версию
|
||||
- Приветственное сообщение с предложением активации триала (С возможностью отключения)
|
||||
|
||||
💰 **Удобные платежи**
|
||||
- ⭐ Telegram Stars
|
||||
@@ -521,11 +560,12 @@ WEBHOOK_PATH=/webhook
|
||||
👥 **Управление пользователями**
|
||||
- 🔍 Поиск и редактирование профилей
|
||||
- 💰 Управление балансами
|
||||
- 📱 **НОВОЕ**: Изменение лимита устройств (1-X)
|
||||
- 📊 **НОВОЕ**: Настройка лимитов трафика (0-10000 ГБ)
|
||||
- 🌍 **НОВОЕ**: Мультивыбор серверов
|
||||
- 🔄 **НОВОЕ**: Сброс HWID устройств
|
||||
- 👥 **НОВОЕ**: Детальная реферальная статистика в профиле пользователя
|
||||
- 📱 Изменение лимита устройств (1-X)
|
||||
- 📊 Настройка лимитов трафика (0-10000 ГБ)
|
||||
- 🌍 Мультивыбор серверов
|
||||
- 🔄 Сброс HWID устройств
|
||||
- 👥 Детальная реферальная статистика в профиле пользователя
|
||||
- 🔄 **НОВОЕ**: Изминение типа подписки триал/платная
|
||||
- 🚫 Блокировка/разблокировка/удаление
|
||||
|
||||
🎫 **Промо-система**
|
||||
@@ -545,7 +585,7 @@ WEBHOOK_PATH=/webhook
|
||||
- 🔔 Автоуведомления о продлении
|
||||
- 💬 Система поддержки с HTML разметкой
|
||||
- 📝 Настройка правил сервиса
|
||||
- Настраиваемое приветственное сообщение с предложением активации триала
|
||||
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
|
||||
|
||||
📨 **Уведомления в закрытый канал**
|
||||
- 🎯 Активация триала
|
||||
@@ -556,6 +596,7 @@ WEBHOOK_PATH=/webhook
|
||||
- ♻️ Выход обновлений бота
|
||||
- 🚧 Потеря соелинения с апи Remnawave
|
||||
- 🗄️ **Бекапы/Восстановление бд**
|
||||
- 🗄️ Отправка бд файлов в отдельный чат/топики
|
||||
|
||||
🗄️ **Бекапы/Восстановление**
|
||||
- Ручной запуск бекапа
|
||||
@@ -568,60 +609,6 @@ WEBHOOK_PATH=/webhook
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Недавние обновления (v2.2.1)
|
||||
|
||||
### 🛡️ **Защищенная реферальная система**
|
||||
- **Защита от абуза**: Бонусы выдаются только при реальном пополнении от 100₽
|
||||
- **Новая логика наград**: При первом пополнении ≥100₽ - новый пользователь и пригласивший получают по 100₽
|
||||
- **Комиссионная система**: Пригласивший получает 25% с последующих пополнений
|
||||
- **Автоматическая миграция**: Обновление статуса существующих пользователей с историей пополнений
|
||||
|
||||
### 🔔 **Умные уведомления**
|
||||
- **Реферальные уведомления**: Автоматические сообщения при переходе по ссылке, первом пополнении и получении комиссий
|
||||
- **Контроль повторов**: Защита от дублирования уведомлений и повторного получения бонусов
|
||||
- **Настраиваемые попытки**: Конфигурируемое количество попыток отправки уведомлений
|
||||
|
||||
### 📊 **Расширенная статистика рефералов**
|
||||
- **Детальная аналитика**: Количество приглашенных, активных рефералов, конверсия и доходы
|
||||
- **Список рефералов**: Просмотр всех приглашенных пользователей с пагинацией
|
||||
- **Топ-рефералы**: Самые прибыльные рефералы с индикацией активности
|
||||
- **Доходы по периодам**: Разбивка заработка по типам начислений
|
||||
|
||||
### 🔧 **Исправления админ-панели**
|
||||
- **Статистика**: Исправлена неработающая кнопка "Обновить" в статистике
|
||||
- **Промокоды**: Исправлена кнопка "Статистика" в разделе управления промокодами
|
||||
- **Навигация**: Синхронизированы callback_data между клавиатурами и обработчиками
|
||||
- **Реферальная статистика**: Добавлена детальная реферальная информация в профилях пользователей
|
||||
|
||||
### 📱 **Улучшения пользовательского интерфейса**
|
||||
- **User-friendly**: Улучшен интерфейс страницы подписки для лучшего UX
|
||||
- **Производительность**: Оптимизированы SQL-запросы для работы с реферальной статистикой
|
||||
- **Обработка ошибок**: Улучшена обработка ошибок при обновлении данных
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Недавние обновления
|
||||
|
||||
### 🛠️ **Управление серверами**
|
||||
- **Мультивыбор серверов**: Админы могут назначать пользователям несколько серверов через удобный переключатель
|
||||
- **Умная логика отображения**: Показывает активные серверы + уже назначенные пользователю неактивные серверы
|
||||
- **Автоматический пропуск**: При наличии только одного сервера шаг выбора автоматически пропускается
|
||||
- **Синхронизация в реальном времени**: Все изменения серверов автоматически синхронизируются с Remnawave панелью
|
||||
|
||||
### 📱 **Управление устройствами**
|
||||
- **Изменение лимита устройств**: Админы могут менять количество разрешенных устройств (1-X)
|
||||
- **Быстрые кнопки**: Предустановленные значения 1, 2, 3, 5, 10 устройств
|
||||
- **Сброс HWID**: Функция сброса привязанных устройств пользователя
|
||||
- **Гибкая настройка**: Отдельные лимиты для триал и платных подписок
|
||||
|
||||
### 📊 **Управление трафиком**
|
||||
- **Настройка лимитов трафика**: Возможность установки лимита от 0 ГБ до 10000 ГБ
|
||||
- **Безлимитный режим**: Опция установки безлимитного трафика (0 ГБ)
|
||||
- **Быстрые настройки**: Предустановленные значения 50, 100, 500, 1000 ГБ
|
||||
- **Два режима продажи**: Фиксированный трафик или выбираемые пакеты
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker развертывание
|
||||
|
||||
### 📄 docker-compose.yml
|
||||
@@ -636,7 +623,7 @@ services:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -652,7 +639,7 @@ services:
|
||||
image: redis:7-alpine
|
||||
container_name: remnawave_bot_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
@@ -675,22 +662,46 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-remnawave_user}:${POSTGRES_PASSWORD:-secure_password_123}@postgres:5432/${POSTGRES_DB:-remnawave_bot}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
DOCKER_ENV: "true"
|
||||
DATABASE_MODE: "auto"
|
||||
POSTGRES_HOST: "postgres"
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
|
||||
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
|
||||
TZ: "Europe/Moscow"
|
||||
volumes:
|
||||
# Логи
|
||||
- ./logs:/app/logs:rw
|
||||
# Данные приложения (для SQLite в случае переключения)
|
||||
- ./data:/app/data:rw
|
||||
# Конфигурация приложения
|
||||
# - ./app-config.json:/app/app-config.json:ro
|
||||
# Timezone
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
# Логотип для сообщений
|
||||
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||
ports:
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
@@ -698,6 +709,7 @@ networks:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
```
|
||||
|
||||
### 🚀 Команды управления
|
||||
@@ -880,6 +892,7 @@ bedolaga_bot/
|
||||
│ │ ├── ✅ subscription_utils.py # Проверка подписок
|
||||
│ │ ├── 📄 pagination.py # Пагинация
|
||||
│ │ ├── 📄 pricing_utils.py # Цены
|
||||
│ │ ├── ‼️ global_error.py # Обработка ошибок
|
||||
│ │ ├── 👤 user_utils.py # Утилиты для пользователей
|
||||
│ │ ├── 🫰 currency_converter.py # Курсы для CryptoBota
|
||||
│ │ └── ⚡ cache.py # Кеширование
|
||||
@@ -918,8 +931,6 @@ bedolaga_bot/
|
||||
|
||||
### 🏥 Health Checks
|
||||
- **Основной**: `http://localhost:8081/health`
|
||||
- **YooKassa**: `http://localhost:8082/health`
|
||||
- **Tribute**: `http://localhost:8081/tribute-webhook`
|
||||
|
||||
### 🔧 Полезные команды
|
||||
```bash
|
||||
@@ -1142,6 +1153,13 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td><strong>@Nav1_0</strong></td>
|
||||
<td>₽1,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td><strong>@kr33njee</strong></td>
|
||||
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
# 🔐 Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Мы обеспечиваем поддержку безопасности для следующих версий:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x.x | ✅ Yes |
|
||||
| 1.x.x | ❌ No |
|
||||
| < 1.0 | ❌ No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
### 🚨 Критические уязвимости
|
||||
|
||||
Для сообщений о критических уязвимостях безопасности:
|
||||
|
||||
**НЕ создавайте публичные Issues или Pull Requests**
|
||||
|
||||
Вместо этого:
|
||||
1. Свяжитесь с [@fringg](https://t.me/fringg) напрямую в Telegram
|
||||
2. Укажите в сообщении "SECURITY VULNERABILITY" в начале
|
||||
3. Опишите уязвимость детально
|
||||
4. Дайте время на исправление (обычно 90 дней)
|
||||
|
||||
### 📧 Контактная информация
|
||||
|
||||
- **Telegram**: [@fringg](https://t.me/fringg)
|
||||
- **Response Time**: 24-48 часов
|
||||
- **Fix Timeline**: 7-30 дней (в зависимости от критичности)
|
||||
|
||||
### 🔍 Что считается уязвимостью безопасности
|
||||
|
||||
**Критические уязвимости:**
|
||||
- SQL инъекции
|
||||
- Remote Code Execution (RCE)
|
||||
- Обход аутентификации
|
||||
- Несанкционированный доступ к данным пользователей
|
||||
- Утечка API ключей или токенов
|
||||
- Bypass платежных проверок
|
||||
|
||||
**Средние уязвимости:**
|
||||
- Cross-Site Scripting (XSS) в веб-компонентах
|
||||
- Небезопасная десериализация
|
||||
- Недостаточная валидация входных данных
|
||||
- Rate limiting bypass
|
||||
|
||||
**Низкие уязвимости:**
|
||||
- Information disclosure
|
||||
- DoS атаки
|
||||
- Проблемы с логированием чувствительных данных
|
||||
|
||||
### 📝 Информация для включения в отчет
|
||||
|
||||
1. **Описание уязвимости**
|
||||
- Тип уязвимости
|
||||
- Затронутые компоненты
|
||||
- Потенциальное воздействие
|
||||
|
||||
2. **Шаги воспроизведения**
|
||||
- Детальные инструкции
|
||||
- Скриншоты или видео (если применимо)
|
||||
- Тестовые данные
|
||||
|
||||
3. **Proof of Concept**
|
||||
- Работающий эксплойт (если безопасно)
|
||||
- Логи или вывод команд
|
||||
|
||||
4. **Предлагаемые исправления**
|
||||
- Возможные решения
|
||||
- Альтернативные подходы
|
||||
|
||||
## Security Measures
|
||||
|
||||
### 🛡️ Текущие меры защиты
|
||||
|
||||
**Аутентификация и авторизация:**
|
||||
- Проверка админ ID через ADMIN_IDS
|
||||
- Валидация всех пользовательских данных
|
||||
- Защита от несанкционированного доступа к админ функциям
|
||||
|
||||
**Защита API:**
|
||||
- Поддержка различных методов аутентификации Remnawave
|
||||
- Безопасная передача куки для защищенных панелей
|
||||
- Rate limiting для предотвращения злоупотреблений
|
||||
- Timeout для внешних запросов
|
||||
|
||||
**Защита данных:**
|
||||
- Хеширование чувствительной информации
|
||||
- Безопасное хранение API ключей
|
||||
- Логирование без записи чувствительных данных
|
||||
- Регулярные бекапы с возможностью восстановления
|
||||
|
||||
**Платежная безопасность:**
|
||||
- Валидация webhook подписей
|
||||
- Проверка сумм платежей
|
||||
- Защита от duplicate payments
|
||||
- Безопасная обработка платежных данных
|
||||
|
||||
**Инфраструктурная безопасность:**
|
||||
- Docker контейнеризация
|
||||
- Изоляция сетевого трафика
|
||||
- Минимальные права доступа
|
||||
- Регулярные обновления зависимостей
|
||||
|
||||
### 🔒 Конфиденциальные данные
|
||||
|
||||
**Что НЕ логируется:**
|
||||
- API ключи и токены
|
||||
- Пароли и секретные ключи
|
||||
- Персональные данные пользователей
|
||||
- Детали платежных транзакций
|
||||
- Webhook секреты
|
||||
|
||||
**Защита в коде:**
|
||||
```python
|
||||
# ✅ Правильно
|
||||
logger.info(f"User {user_id} created subscription")
|
||||
logger.debug(f"Payment webhook received for order {order_id}")
|
||||
|
||||
# ❌ Неправильно
|
||||
logger.info(f"API key: {api_key}")
|
||||
logger.debug(f"Webhook payload: {webhook_data}")
|
||||
```
|
||||
|
||||
### 🌐 Сетевая безопасность
|
||||
|
||||
**HTTPS обязателен:**
|
||||
- Все внешние API вызовы через HTTPS
|
||||
- Webhook endpoints защищены TLS
|
||||
- Проверка SSL сертификатов
|
||||
|
||||
**Firewall конфигурация:**
|
||||
```bash
|
||||
# Открыть только необходимые порты
|
||||
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
|
||||
ufw deny 8082/tcp
|
||||
```
|
||||
|
||||
### 📊 Мониторинг безопасности
|
||||
|
||||
**Автоматические проверки:**
|
||||
- Мониторинг неудачных попыток аутентификации
|
||||
- Отслеживание подозрительной активности
|
||||
- Проверка целостности платежей
|
||||
- Мониторинг состояния внешних сервисов
|
||||
|
||||
**Алерты:**
|
||||
- Множественные неудачные попытки входа
|
||||
- Неожиданные изменения в конфигурации
|
||||
- Критические ошибки в платежных системах
|
||||
- Недоступность Remnawave API
|
||||
|
||||
## Best Practices for Developers
|
||||
|
||||
### 🔐 Безопасное программирование
|
||||
|
||||
**Input Validation:**
|
||||
```python
|
||||
# ✅ Правильно
|
||||
def validate_user_id(user_id: str) -> int:
|
||||
if not user_id.isdigit():
|
||||
raise ValueError("Invalid user ID format")
|
||||
uid = int(user_id)
|
||||
if uid <= 0:
|
||||
raise ValueError("User ID must be positive")
|
||||
return uid
|
||||
|
||||
# ❌ Неправильно
|
||||
def get_user(user_id):
|
||||
return session.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
||||
```
|
||||
|
||||
**SQL Injection Prevention:**
|
||||
```python
|
||||
# ✅ Правильно - используйте SQLAlchemy ORM
|
||||
async def get_user_subscriptions(user_id: int):
|
||||
result = await session.execute(
|
||||
select(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
# ❌ Неправильно - raw SQL без параметров
|
||||
async def get_user_subscriptions(user_id: int):
|
||||
result = await session.execute(
|
||||
f"SELECT * FROM subscriptions WHERE user_id = {user_id}"
|
||||
)
|
||||
```
|
||||
|
||||
**API Keys Management:**
|
||||
```python
|
||||
# ✅ Правильно
|
||||
class Config:
|
||||
REMNAWAVE_API_KEY: str = os.getenv("REMNAWAVE_API_KEY", "")
|
||||
|
||||
def validate_required_env(self):
|
||||
if not self.REMNAWAVE_API_KEY:
|
||||
raise ValueError("REMNAWAVE_API_KEY is required")
|
||||
|
||||
# ❌ Неправильно
|
||||
API_KEY = "your_api_key_here" # Никогда не хардкодьте ключи
|
||||
```
|
||||
|
||||
**Error Handling:**
|
||||
```python
|
||||
# ✅ Правильно - не раскрывайте внутреннюю информацию
|
||||
try:
|
||||
result = await remnawave_api.create_subscription(data)
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"RemnaWave API error: {e}")
|
||||
await message.answer("Временные проблемы с сервисом. Попробуйте позже.")
|
||||
|
||||
# ❌ Неправильно - раскрытие внутренних деталей
|
||||
except Exception as e:
|
||||
await message.answer(f"Error: {str(e)}")
|
||||
```
|
||||
|
||||
### 🔍 Code Review Security Checklist
|
||||
|
||||
- [ ] Нет хардкода API ключей или паролей
|
||||
- [ ] Все пользовательские данные валидируются
|
||||
- [ ] SQL запросы используют параметризированные запросы
|
||||
- [ ] Ошибки логируются без раскрытия чувствительной информации
|
||||
- [ ] Внешние запросы используют таймауты
|
||||
- [ ] Webhook подписи проверяются
|
||||
- [ ] Rate limiting применяется где необходимо
|
||||
- [ ] Авторизация проверяется для защищенных операций
|
||||
|
||||
### 📋 Environment Variables Security
|
||||
|
||||
**Обязательные переменные:**
|
||||
```env
|
||||
# Критически важные - должны быть установлены
|
||||
BOT_TOKEN=your_bot_token
|
||||
REMNAWAVE_API_KEY=your_api_key
|
||||
ADMIN_IDS=123456789,987654321
|
||||
|
||||
# Webhook секреты - генерируйте случайные значения
|
||||
YOOKASSA_WEBHOOK_SECRET=random_secret_here
|
||||
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
||||
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
||||
```
|
||||
|
||||
**Генерация безопасных секретов:**
|
||||
```bash
|
||||
# Генерация случайного секрета
|
||||
openssl rand -hex 32
|
||||
|
||||
# Или используйте Python
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
### 🐳 Docker Security
|
||||
|
||||
**Dockerfile best practices:**
|
||||
```dockerfile
|
||||
# Не запускайтесь под root
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
USER appuser
|
||||
|
||||
# Минимальные права на файлы
|
||||
COPY --chown=appuser:appuser . /app
|
||||
|
||||
# Удаляйте ненужные пакеты
|
||||
RUN apt-get remove --purge -y build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
apt-get clean
|
||||
```
|
||||
|
||||
**docker-compose.yml security:**
|
||||
```yaml
|
||||
services:
|
||||
bot:
|
||||
# Ограничьте ресурсы
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
|
||||
# Не пробрасывайте порты наружу без необходимости
|
||||
ports:
|
||||
- "127.0.0.1:8081:8081" # Только localhost
|
||||
|
||||
# Read-only root filesystem
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
```
|
||||
|
||||
## Security Updates
|
||||
|
||||
### 📅 График обновлений
|
||||
|
||||
- **Критические уязвимости**: Исправляются в течение 24-48 часов
|
||||
- **Высокий приоритет**: Исправляются в течение 7 дней
|
||||
- **Средний приоритет**: Исправляются в течение 30 дней
|
||||
- **Низкий приоритет**: Исправляются в следующем планируемом релизе
|
||||
|
||||
### 🔄 Процесс обновления
|
||||
|
||||
1. **Уведомление**: Администраторы получают уведомление о доступных обновлениях
|
||||
2. **Тестирование**: Обновления тестируются в изолированной среде
|
||||
3. **Развертывание**: Постепенное развертывание с возможностью отката
|
||||
4. **Мониторинг**: Наблюдение за стабильностью после обновления
|
||||
|
||||
### 📢 Уведомления о безопасности
|
||||
|
||||
Подпишитесь на уведомления:
|
||||
- **Telegram**: [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)
|
||||
- **GitHub**: Watch repository для получения уведомлений о релизах
|
||||
- **RSS**: Следите за GitHub Releases
|
||||
|
||||
## Compliance and Standards
|
||||
|
||||
### 📜 Соответствие стандартам
|
||||
|
||||
**GDPR Compliance:**
|
||||
- Минимизация сбора данных
|
||||
- Право на удаление данных
|
||||
- Уведомление о нарушениях безопасности
|
||||
- Согласие на обработку данных
|
||||
|
||||
**PCI DSS (для платежей):**
|
||||
- Не храним данные банковских карт
|
||||
- Используем сертифицированные платежные провайдеры
|
||||
- Шифрование чувствительных данных
|
||||
- Регулярные аудиты безопасности
|
||||
|
||||
### 🛡️ Third-party Security
|
||||
|
||||
**Используемые сервисы:**
|
||||
- **Telegram API**: Официальный API с высоким уровнем безопасности
|
||||
- **Remnawave**: VPN панель с собственными мерами безопасности
|
||||
- **YooKassa**: PCI DSS сертифицированный платежный провайдер
|
||||
- **CryptoBot**: Криптовалютные платежи с блокчейн безопасностью
|
||||
|
||||
**Регулярные проверки:**
|
||||
- Мониторинг безопасности dependencies
|
||||
- Обновление библиотек и зависимостей
|
||||
- Проверка CVE для используемых компонентов
|
||||
|
||||
## Incident Response
|
||||
|
||||
### 🚨 В случае инцидента безопасности
|
||||
|
||||
1. **Немедленно**:
|
||||
- Изолируйте затронутую систему
|
||||
- Сохраните логи и доказательства
|
||||
- Уведомите команду разработки
|
||||
|
||||
2. **В течение часа**:
|
||||
- Оцените масштаб инцидента
|
||||
- Определите затронутых пользователей
|
||||
- Начните процедуры восстановления
|
||||
|
||||
3. **В течение дня**:
|
||||
- Устраните уязвимость
|
||||
- Восстановите нормальную работу
|
||||
- Уведомите пользователей (если необходимо)
|
||||
|
||||
4. **После инцидента**:
|
||||
- Проведите post-mortem анализ
|
||||
- Обновите процедуры безопасности
|
||||
- Внедрите дополнительные защитные меры
|
||||
|
||||
### 📊 Отчетность
|
||||
|
||||
**Внутренние отчеты:**
|
||||
- Еженедельные отчеты о безопасности
|
||||
- Ежемесячные аудиты логов
|
||||
- Квартальные обзоры политик безопасности
|
||||
|
||||
**Внешние уведомления:**
|
||||
- Уведомление пользователей о критических обновлениях
|
||||
- Публикация Security Advisories при необходимости
|
||||
- Сотрудничество с исследователями безопасности
|
||||
|
||||
---
|
||||
|
||||
**Помните**: Безопасность - это постоянный процесс, а не одноразовая настройка. Регулярно обновляйте зависимости, следите за новостями безопасности и не стесняйтесь сообщать о подозрительной активности.
|
||||
+9
-5
@@ -5,6 +5,8 @@ from aiogram.fsm.storage.memory import MemoryStorage
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.throttling import ThrottlingMiddleware
|
||||
@@ -74,7 +76,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
storage = MemoryStorage()
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
@@ -86,7 +91,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
dp.message.middleware(SubscriptionStatusMiddleware())
|
||||
dp.callback_query.middleware(SubscriptionStatusMiddleware())
|
||||
|
||||
dp.message.middleware(ChannelCheckerMiddleware())
|
||||
dp.callback_query.middleware(ChannelCheckerMiddleware())
|
||||
start.register_handlers(dp)
|
||||
menu.register_handlers(dp)
|
||||
subscription.register_handlers(dp)
|
||||
@@ -94,7 +100,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
promocode.register_handlers(dp)
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
@@ -111,9 +116,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_updates.register_handlers(dp)
|
||||
admin_backup.register_handlers(dp)
|
||||
admin_welcome_text.register_welcome_text_handlers(dp)
|
||||
|
||||
common.register_handlers(dp)
|
||||
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
@@ -123,6 +126,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
return bot, dp
|
||||
|
||||
+69
-2
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
@@ -14,6 +16,10 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
|
||||
DATABASE_URL: str = ""
|
||||
|
||||
@@ -32,6 +38,11 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_API_URL: str
|
||||
REMNAWAVE_API_KEY: str
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
|
||||
REMNAWAVE_USERNAME: Optional[str] = None
|
||||
REMNAWAVE_PASSWORD: Optional[str] = None
|
||||
REMNAWAVE_AUTH_TYPE: str = "api_key"
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
@@ -138,9 +149,12 @@ class Settings(BaseSettings):
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
HIDE_SUBSCRIPTION_LINK: bool = False
|
||||
ENABLE_LOGO_MODE: bool = True
|
||||
LOGO_FILE: str = "vpn_logo.png"
|
||||
|
||||
SKIP_RULES_ACCEPT: bool = False
|
||||
SKIP_REFERRAL_CODE: bool = False
|
||||
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
|
||||
@@ -166,6 +180,9 @@ class Settings(BaseSettings):
|
||||
BACKUP_COMPRESSION: bool = True
|
||||
BACKUP_INCLUDE_LOGS: bool = False
|
||||
BACKUP_LOCATION: str = "/app/data/backups"
|
||||
BACKUP_SEND_ENABLED: bool = False
|
||||
BACKUP_SEND_CHAT_ID: Optional[str] = None
|
||||
BACKUP_SEND_TOPIC_ID: Optional[int] = None
|
||||
|
||||
@field_validator('LOG_FILE', mode='before')
|
||||
@classmethod
|
||||
@@ -232,6 +249,43 @@ class Settings(BaseSettings):
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
def get_remnawave_auth_params(self) -> Dict[str, Optional[str]]:
|
||||
return {
|
||||
"base_url": self.REMNAWAVE_API_URL,
|
||||
"api_key": self.REMNAWAVE_API_KEY,
|
||||
"secret_key": self.REMNAWAVE_SECRET_KEY,
|
||||
"username": self.REMNAWAVE_USERNAME,
|
||||
"password": self.REMNAWAVE_PASSWORD,
|
||||
"auth_type": self.REMNAWAVE_AUTH_TYPE
|
||||
}
|
||||
|
||||
def format_remnawave_user_description(
|
||||
self,
|
||||
*,
|
||||
full_name: str,
|
||||
username: Optional[str],
|
||||
telegram_id: int
|
||||
) -> str:
|
||||
template = self.REMNAWAVE_USER_DESCRIPTION_TEMPLATE or "Bot user: {full_name} {username}"
|
||||
template_for_formatting = template.replace("@{username}", "{username}")
|
||||
|
||||
username_clean = (username or "").lstrip("@")
|
||||
values = defaultdict(str, {
|
||||
"full_name": full_name,
|
||||
"username": f"@{username_clean}" if username_clean else "",
|
||||
"username_clean": username_clean,
|
||||
"telegram_id": str(telegram_id)
|
||||
})
|
||||
|
||||
description = template_for_formatting.format_map(values)
|
||||
|
||||
if not username_clean:
|
||||
description = re.sub(r'@(?=\W|$)', '', description)
|
||||
description = re.sub(r'\(\s*\)', '', description)
|
||||
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
return description
|
||||
|
||||
def get_autopay_warning_days(self) -> List[int]:
|
||||
try:
|
||||
@@ -413,9 +467,22 @@ class Settings(BaseSettings):
|
||||
return None
|
||||
|
||||
def is_admin_notifications_enabled(self) -> bool:
|
||||
return (self.ADMIN_NOTIFICATIONS_ENABLED and
|
||||
return (self.ADMIN_NOTIFICATIONS_ENABLED and
|
||||
self.get_admin_notifications_chat_id() is not None)
|
||||
|
||||
def get_backup_send_chat_id(self) -> Optional[int]:
|
||||
if not self.BACKUP_SEND_CHAT_ID:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(self.BACKUP_SEND_CHAT_ID)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def is_backup_send_enabled(self) -> bool:
|
||||
return (self.BACKUP_SEND_ENABLED and
|
||||
self.get_backup_send_chat_id() is not None)
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
return {
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
|
||||
+139
-3
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
@@ -53,10 +53,37 @@ async def create_or_update_rules(
|
||||
await db.commit()
|
||||
await db.refresh(new_rules)
|
||||
|
||||
logger.info(f"✅ Правила для языка {language} обновлены")
|
||||
logger.info(f"✅ Правила для языка {language} обновлены (ID: {new_rules.id})")
|
||||
return new_rules
|
||||
|
||||
|
||||
async def clear_all_rules(db: AsyncSession, language: str = "ru") -> bool:
|
||||
try:
|
||||
result = await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
rows_affected = result.rowcount
|
||||
logger.info(f"✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}")
|
||||
|
||||
return rows_affected > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при очистке правил для языка {language}: {e}")
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str:
|
||||
rules = await get_rules_by_language(db, language)
|
||||
|
||||
@@ -79,4 +106,113 @@ async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> s
|
||||
6. При возникновении вопросов обращайтесь в техническую поддержку.
|
||||
|
||||
Используя сервис, вы соглашаетесь с данными правилами.
|
||||
"""
|
||||
"""
|
||||
|
||||
|
||||
async def get_all_rules_versions(
|
||||
db: AsyncSession,
|
||||
language: str = "ru",
|
||||
limit: int = 10
|
||||
) -> list[ServiceRule]:
|
||||
result = await db.execute(
|
||||
select(ServiceRule)
|
||||
.where(ServiceRule.language == language)
|
||||
.order_by(ServiceRule.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def restore_rules_version(
|
||||
db: AsyncSession,
|
||||
rule_id: int,
|
||||
language: str = "ru"
|
||||
) -> Optional[ServiceRule]:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(ServiceRule).where(
|
||||
ServiceRule.id == rule_id,
|
||||
ServiceRule.language == language
|
||||
)
|
||||
)
|
||||
rule_to_restore = result.scalar_one_or_none()
|
||||
|
||||
if not rule_to_restore:
|
||||
logger.warning(f"Правило с ID {rule_id} не найдено для языка {language}")
|
||||
return None
|
||||
|
||||
await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
restored_rule = ServiceRule(
|
||||
title=rule_to_restore.title,
|
||||
content=rule_to_restore.content,
|
||||
language=language,
|
||||
is_active=True,
|
||||
order=0
|
||||
)
|
||||
|
||||
db.add(restored_rule)
|
||||
await db.commit()
|
||||
await db.refresh(restored_rule)
|
||||
|
||||
logger.info(f"✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}")
|
||||
return restored_rule
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при восстановлении правил ID {rule_id}: {e}")
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_rules_statistics(db: AsyncSession) -> dict:
|
||||
try:
|
||||
active_result = await db.execute(
|
||||
select(ServiceRule).where(ServiceRule.is_active == True)
|
||||
)
|
||||
active_rules = active_result.scalars().all()
|
||||
|
||||
all_result = await db.execute(select(ServiceRule))
|
||||
all_rules = all_result.scalars().all()
|
||||
|
||||
languages_stats = {}
|
||||
for rule in active_rules:
|
||||
lang = rule.language
|
||||
if lang not in languages_stats:
|
||||
languages_stats[lang] = {
|
||||
'active_count': 0,
|
||||
'last_updated': None,
|
||||
'content_length': 0
|
||||
}
|
||||
|
||||
languages_stats[lang]['active_count'] += 1
|
||||
languages_stats[lang]['content_length'] = len(rule.content)
|
||||
|
||||
if not languages_stats[lang]['last_updated'] or rule.updated_at > languages_stats[lang]['last_updated']:
|
||||
languages_stats[lang]['last_updated'] = rule.updated_at
|
||||
|
||||
return {
|
||||
'total_active': len(active_rules),
|
||||
'total_all_time': len(all_rules),
|
||||
'languages': languages_stats,
|
||||
'total_languages': len(languages_stats)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при получении статистики правил: {e}")
|
||||
return {
|
||||
'total_active': 0,
|
||||
'total_all_time': 0,
|
||||
'languages': {},
|
||||
'total_languages': 0,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy import select, and_, func, update, delete
|
||||
from sqlalchemy import select, and_, func, update, delete, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad, SubscriptionServer
|
||||
from app.database.models import ServerSquad, SubscriptionServer, Subscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -137,7 +137,7 @@ async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
|
||||
connections_count = connections_result.scalar()
|
||||
|
||||
if connections_count > 0:
|
||||
logger.warning(f"❌ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
|
||||
logger.warning(f"⚠ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
|
||||
return False
|
||||
|
||||
await db.execute(
|
||||
@@ -244,10 +244,23 @@ async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
available_servers = available_result.scalar()
|
||||
|
||||
with_connections_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionServer.server_squad_id)))
|
||||
)
|
||||
servers_with_connections = with_connections_result.scalar()
|
||||
servers_with_connections = 0
|
||||
all_servers_result = await db.execute(select(ServerSquad.squad_uuid))
|
||||
all_server_uuids = [row[0] for row in all_servers_result.fetchall()]
|
||||
|
||||
for squad_uuid in all_server_uuids:
|
||||
count_result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(s.id)
|
||||
FROM subscriptions s
|
||||
WHERE s.status IN ('active', 'trial')
|
||||
AND s.connected_squads::text LIKE :uuid_pattern
|
||||
"""),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
user_count = count_result.scalar() or 0
|
||||
if user_count > 0:
|
||||
servers_with_connections += 1
|
||||
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0))
|
||||
@@ -324,20 +337,26 @@ async def get_server_ids_by_uuids(
|
||||
async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(
|
||||
ServerSquad.id,
|
||||
ServerSquad.squad_uuid,
|
||||
func.count(SubscriptionServer.id).label('actual_users')
|
||||
)
|
||||
.outerjoin(SubscriptionServer, ServerSquad.id == SubscriptionServer.server_squad_id)
|
||||
.join(Subscription, SubscriptionServer.subscription_id == Subscription.id)
|
||||
.where(Subscription.status == 'active')
|
||||
.group_by(ServerSquad.id, ServerSquad.squad_uuid)
|
||||
)
|
||||
all_servers_result = await db.execute(select(ServerSquad.id, ServerSquad.squad_uuid))
|
||||
all_servers = all_servers_result.fetchall()
|
||||
|
||||
logger.info(f"🔍 Найдено серверов для синхронизации: {len(all_servers)}")
|
||||
|
||||
updated_count = 0
|
||||
for server_id, squad_uuid, actual_users in result.fetchall():
|
||||
for server_id, squad_uuid in all_servers:
|
||||
count_result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(s.id)
|
||||
FROM subscriptions s
|
||||
WHERE s.status IN ('active', 'trial')
|
||||
AND s.connected_squads::text LIKE :uuid_pattern
|
||||
"""),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
actual_users = count_result.scalar() or 0
|
||||
|
||||
logger.info(f"📊 Сервер {server_id} ({squad_uuid[:8]}): {actual_users} пользователей")
|
||||
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
@@ -352,4 +371,4 @@ async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации счетчиков пользователей: {e}")
|
||||
await db.rollback()
|
||||
return 0
|
||||
return 0
|
||||
|
||||
@@ -14,6 +14,7 @@ async def get_active_welcome_text(db: AsyncSession) -> Optional[str]:
|
||||
result = await db.execute(
|
||||
select(WelcomeText)
|
||||
.where(WelcomeText.is_active == True)
|
||||
.where(WelcomeText.is_enabled == True)
|
||||
.order_by(WelcomeText.updated_at.desc())
|
||||
)
|
||||
welcome_text = result.scalar_one_or_none()
|
||||
@@ -23,8 +24,72 @@ async def get_active_welcome_text(db: AsyncSession) -> Optional[str]:
|
||||
|
||||
return None
|
||||
|
||||
async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
|
||||
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 {
|
||||
'text': welcome_text.text_content,
|
||||
'is_enabled': welcome_text.is_enabled,
|
||||
'id': welcome_text.id
|
||||
}
|
||||
|
||||
return {
|
||||
'text': await get_current_welcome_text_or_default(),
|
||||
'is_enabled': True,
|
||||
'id': None
|
||||
}
|
||||
|
||||
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
|
||||
try:
|
||||
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:
|
||||
welcome_text.is_enabled = not welcome_text.is_enabled
|
||||
welcome_text.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
status = "включен" if welcome_text.is_enabled else "отключен"
|
||||
logger.info(f"Приветственный текст {status} администратором {admin_id}")
|
||||
return welcome_text.is_enabled
|
||||
else:
|
||||
default_text = await get_current_welcome_text_or_default()
|
||||
new_welcome_text = WelcomeText(
|
||||
text_content=default_text,
|
||||
is_active=True,
|
||||
is_enabled=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 set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -> bool:
|
||||
try:
|
||||
current_settings = await get_current_welcome_text_settings(db)
|
||||
current_enabled_status = current_settings.get('is_enabled', True)
|
||||
|
||||
await db.execute(
|
||||
update(WelcomeText).values(is_active=False)
|
||||
)
|
||||
@@ -32,6 +97,7 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -
|
||||
new_welcome_text = WelcomeText(
|
||||
text_content=text_content,
|
||||
is_active=True,
|
||||
is_enabled=current_enabled_status,
|
||||
created_by=admin_id
|
||||
)
|
||||
|
||||
@@ -54,7 +120,7 @@ async def get_current_welcome_text_or_default() -> str:
|
||||
f"✅ До 1 Гбит/с скорость "
|
||||
f"✅ Умный VPN — можно не отключать для большинства российских сервисов "
|
||||
f"✅ Современные протоколы — максимум защиты и анонимности "
|
||||
f"👉 Всего 99₽/мес за 1 устройство "
|
||||
f"💉 Всего 99₽/мес за 1 устройство "
|
||||
f"👇 Жмите кнопку и подключайтесь!"
|
||||
)
|
||||
|
||||
@@ -85,11 +151,10 @@ def replace_placeholders(text: str, user) -> str:
|
||||
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()
|
||||
return None
|
||||
|
||||
if isinstance(user, str):
|
||||
class SimpleUser:
|
||||
|
||||
@@ -575,6 +575,10 @@ class BroadcastHistory(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
media_caption = Column(Text, nullable=True)
|
||||
total_count = Column(Integer, default=0)
|
||||
sent_count = Column(Integer, default=0)
|
||||
failed_count = Column(Integer, default=0)
|
||||
@@ -583,7 +587,6 @@ class BroadcastHistory(Base):
|
||||
admin_name = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
class ServerSquad(Base):
|
||||
@@ -667,6 +670,7 @@ class WelcomeText(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
text_content = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_enabled = 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())
|
||||
|
||||
@@ -248,11 +248,49 @@ async def create_user_messages_table():
|
||||
logger.error(f"Ошибка создания таблицы user_messages: {e}")
|
||||
return False
|
||||
|
||||
async def add_welcome_text_is_enabled_column():
|
||||
column_exists = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
if column_exists:
|
||||
logger.info("Колонка is_enabled уже существует в таблице welcome_texts")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT 1 NOT NULL"
|
||||
elif db_type == 'postgresql':
|
||||
alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT TRUE NOT NULL"
|
||||
elif db_type == 'mysql':
|
||||
alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT TRUE NOT NULL"
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для добавления колонки: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info("✅ Поле is_enabled добавлено в таблицу welcome_texts")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
update_sql = "UPDATE welcome_texts SET is_enabled = 1 WHERE is_enabled IS NULL"
|
||||
else:
|
||||
update_sql = "UPDATE welcome_texts SET is_enabled = TRUE WHERE is_enabled IS NULL"
|
||||
|
||||
result = await conn.execute(text(update_sql))
|
||||
updated_count = result.rowcount
|
||||
logger.info(f"Обновлено {updated_count} существующих записей welcome_texts")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении поля is_enabled: {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
|
||||
return await add_welcome_text_is_enabled_column()
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -264,6 +302,7 @@ async def create_welcome_texts_table():
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text_content TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
is_enabled BOOLEAN DEFAULT 1 NOT NULL,
|
||||
created_by INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -271,6 +310,7 @@ async def create_welcome_texts_table():
|
||||
);
|
||||
|
||||
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
|
||||
CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled);
|
||||
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
|
||||
"""
|
||||
|
||||
@@ -280,6 +320,7 @@ async def create_welcome_texts_table():
|
||||
id SERIAL PRIMARY KEY,
|
||||
text_content TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_enabled BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_by INTEGER NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -287,6 +328,7 @@ async def create_welcome_texts_table():
|
||||
);
|
||||
|
||||
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
|
||||
CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled);
|
||||
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
|
||||
"""
|
||||
|
||||
@@ -296,6 +338,7 @@ async def create_welcome_texts_table():
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
text_content TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_enabled BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -303,6 +346,7 @@ async def create_welcome_texts_table():
|
||||
);
|
||||
|
||||
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
|
||||
CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled);
|
||||
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
|
||||
"""
|
||||
else:
|
||||
@@ -310,13 +354,56 @@ async def create_welcome_texts_table():
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица welcome_texts успешно создана")
|
||||
logger.info("✅ Таблица welcome_texts успешно создана с полем is_enabled")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы welcome_texts: {e}")
|
||||
return False
|
||||
|
||||
async def add_media_fields_to_broadcast_history():
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ МЕДИА В BROADCAST_HISTORY ===")
|
||||
|
||||
media_fields = {
|
||||
'has_media': 'BOOLEAN DEFAULT FALSE',
|
||||
'media_type': 'VARCHAR(20)',
|
||||
'media_file_id': 'VARCHAR(255)',
|
||||
'media_caption': 'TEXT'
|
||||
}
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
for field_name, field_type in media_fields.items():
|
||||
field_exists = await check_column_exists('broadcast_history', field_name)
|
||||
|
||||
if not field_exists:
|
||||
logger.info(f"Добавление поля {field_name} в таблицу broadcast_history")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT 0')
|
||||
elif db_type == 'postgresql':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT FALSE')
|
||||
elif db_type == 'mysql':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT FALSE')
|
||||
|
||||
alter_sql = f"ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}"
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info(f"✅ Поле {field_name} успешно добавлено")
|
||||
else:
|
||||
logger.info(f"Поле {field_name} уже существует в broadcast_history")
|
||||
|
||||
logger.info("✅ Все поля медиа в broadcast_history готовы")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении полей медиа в broadcast_history: {e}")
|
||||
return False
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -588,13 +675,20 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей user_messages")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===")
|
||||
logger.info("=== СОЗДАНИЕ/ОБНОВЛЕНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===")
|
||||
welcome_texts_created = await create_welcome_texts_table()
|
||||
if welcome_texts_created:
|
||||
logger.info("✅ Таблица welcome_texts готова")
|
||||
logger.info("✅ Таблица welcome_texts готова с полем is_enabled")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей welcome_texts")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ МЕДИА ПОЛЕЙ В BROADCAST_HISTORY ===")
|
||||
media_fields_added = await add_media_fields_to_broadcast_history()
|
||||
if media_fields_added:
|
||||
logger.info("✅ Медиа поля в broadcast_history готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением медиа полей")
|
||||
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -644,6 +738,8 @@ async def run_universal_migration():
|
||||
logger.info("✅ Реферальная система обновлена")
|
||||
logger.info("✅ CryptoBot таблица готова")
|
||||
logger.info("✅ Таблица конверсий подписок создана")
|
||||
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
|
||||
logger.info("✅ Медиа поля в broadcast_history добавлены")
|
||||
logger.info("✅ Дубликаты подписок исправлены")
|
||||
return True
|
||||
|
||||
@@ -660,6 +756,8 @@ async def check_migration_status():
|
||||
"cryptobot_table": False,
|
||||
"user_messages_table": False,
|
||||
"welcome_texts_table": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
"broadcast_history_media_fields": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False
|
||||
}
|
||||
@@ -667,13 +765,20 @@ async def check_migration_status():
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
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')
|
||||
|
||||
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
await check_column_exists('broadcast_history', 'media_type') and
|
||||
await check_column_exists('broadcast_history', 'media_file_id') and
|
||||
await check_column_exists('broadcast_history', 'media_caption')
|
||||
)
|
||||
status["broadcast_history_media_fields"] = media_fields_exist
|
||||
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(text("""
|
||||
SELECT COUNT(*) FROM (
|
||||
@@ -691,6 +796,8 @@ async def check_migration_status():
|
||||
"cryptobot_table": "Таблица CryptoBot payments",
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
}
|
||||
@@ -709,12 +816,14 @@ async def check_migration_status():
|
||||
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"))
|
||||
broadcasts_count = await conn.execute(text("SELECT COUNT(*) FROM broadcast_history"))
|
||||
|
||||
conv_count = conversions_count.fetchone()[0]
|
||||
usr_count = users_count.fetchone()[0]
|
||||
welcome_count = welcome_texts_count.fetchone()[0]
|
||||
broadcast_count = broadcasts_count.fetchone()[0]
|
||||
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов")
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов, {broadcast_count} рассылок")
|
||||
except Exception as stats_error:
|
||||
logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}")
|
||||
|
||||
|
||||
Vendored
+30
-10
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Union, Any
|
||||
from urllib.parse import urlparse
|
||||
@@ -103,12 +104,15 @@ class RemnaWaveAPIError(Exception):
|
||||
|
||||
class RemnaWaveAPI:
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None):
|
||||
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None,
|
||||
username: Optional[str] = None, password: Optional[str] = None):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.secret_key = secret_key
|
||||
self.secret_key = secret_key
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.authenticated = False
|
||||
self.authenticated = False
|
||||
|
||||
def _detect_connection_type(self) -> str:
|
||||
parsed = urlparse(self.base_url)
|
||||
@@ -129,14 +133,9 @@ class RemnaWaveAPI:
|
||||
return "local"
|
||||
|
||||
return "external"
|
||||
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
|
||||
def _prepare_auth_headers(self) -> Dict[str, str]:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
@@ -144,6 +143,27 @@ class RemnaWaveAPI:
|
||||
'X-Real-IP': '127.0.0.1'
|
||||
}
|
||||
|
||||
if self.username and self.password:
|
||||
import base64
|
||||
credentials = f"{self.username}:{self.password}"
|
||||
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
||||
headers['X-Api-Key'] = f"Basic {encoded_credentials}"
|
||||
logger.debug("Используем Basic Auth в X-Api-Key заголовке")
|
||||
else:
|
||||
headers['X-Api-Key'] = self.api_key
|
||||
logger.debug("Используем API ключ в X-Api-Key заголовке")
|
||||
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
|
||||
return headers
|
||||
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
headers = self._prepare_auth_headers()
|
||||
|
||||
cookies = None
|
||||
if self.secret_key:
|
||||
if ':' in self.secret_key:
|
||||
|
||||
+275
-2
@@ -1,12 +1,22 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.keyboards.admin import (
|
||||
get_admin_main_keyboard,
|
||||
get_admin_users_submenu_keyboard,
|
||||
get_admin_promo_submenu_keyboard,
|
||||
get_admin_communications_submenu_keyboard,
|
||||
get_admin_settings_submenu_keyboard,
|
||||
get_admin_system_submenu_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import clear_all_rules, get_rules_statistics
|
||||
from app.localization.texts import clear_rules_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,6 +31,25 @@ async def show_admin_panel(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
admin_text = texts.ADMIN_PANEL
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
remnawave_service = RemnaWaveService()
|
||||
stats = await remnawave_service.get_system_statistics()
|
||||
system_stats = stats.get("system", {})
|
||||
users_online = system_stats.get("users_online", 0)
|
||||
users_today = system_stats.get("users_last_day", 0)
|
||||
users_week = system_stats.get("users_last_week", 0)
|
||||
admin_text = admin_text.replace(
|
||||
"\n\nВыберите раздел для управления:",
|
||||
(
|
||||
f"\n\n- 🟢 Онлайн сейчас: {users_online}"
|
||||
f"\n- 📅 Онлайн сегодня: {users_today}"
|
||||
f"\n- 🗓️ На этой неделе: {users_week}"
|
||||
"\n\nВыберите раздел для управления:"
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось получить статистику Remnawave для админ-панели: {e}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
admin_text,
|
||||
@@ -29,9 +58,253 @@ async def show_admin_panel(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"👥 **Управление пользователями и подписками**\n\n"
|
||||
"Выберите нужный раздел:",
|
||||
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"💰 **Промокоды и статистика**\n\n"
|
||||
"Выберите нужный раздел:",
|
||||
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_communications_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 **Коммуникации**\n\n"
|
||||
"Управление рассылками и текстами интерфейса:",
|
||||
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_settings_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⚙️ **Настройки системы**\n\n"
|
||||
"Управление Remnawave, мониторингом и другими настройками:",
|
||||
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_system_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🛠️ **Системные функции**\n\n"
|
||||
"Обновления, резервные копии и системные операции:",
|
||||
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_rules_command(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_rules_statistics(db)
|
||||
|
||||
if stats['total_active'] == 0:
|
||||
await message.reply(
|
||||
"ℹ️ <b>Правила уже очищены</b>\n\n"
|
||||
"В системе нет активных правил. Используются стандартные правила по умолчанию."
|
||||
)
|
||||
return
|
||||
|
||||
success = await clear_all_rules(db, db_user.language)
|
||||
|
||||
if success:
|
||||
clear_rules_cache()
|
||||
|
||||
await message.reply(
|
||||
f"✅ <b>Правила успешно очищены!</b>\n\n"
|
||||
f"📊 <b>Статистика:</b>\n"
|
||||
f"• Очищено правил: {stats['total_active']}\n"
|
||||
f"• Язык: {db_user.language}\n"
|
||||
f"• Выполнил: {db_user.full_name}\n\n"
|
||||
f"Теперь используются стандартные правила по умолчанию."
|
||||
)
|
||||
|
||||
logger.info(f"Правила очищены командой администратором {db_user.telegram_id} ({db_user.full_name})")
|
||||
else:
|
||||
await message.reply(
|
||||
"⚠️ <b>Нет правил для очистки</b>\n\n"
|
||||
"Активные правила не найдены."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке правил командой: {e}")
|
||||
await message.reply(
|
||||
"❌ <b>Ошибка при очистке правил</b>\n\n"
|
||||
f"Произошла ошибка: {str(e)}\n"
|
||||
"Попробуйте через админ-панель или повторите позже."
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def rules_stats_command(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_rules_statistics(db)
|
||||
|
||||
if 'error' in stats:
|
||||
await message.reply(f"❌ Ошибка получения статистики: {stats['error']}")
|
||||
return
|
||||
|
||||
text = f"📊 <b>Статистика правил сервиса</b>\n\n"
|
||||
text += f"📋 <b>Общая информация:</b>\n"
|
||||
text += f"• Активных правил: {stats['total_active']}\n"
|
||||
text += f"• Всего в истории: {stats['total_all_time']}\n"
|
||||
text += f"• Поддерживаемых языков: {stats['total_languages']}\n\n"
|
||||
|
||||
if stats['languages']:
|
||||
text += f"🌐 <b>По языкам:</b>\n"
|
||||
for lang, lang_stats in stats['languages'].items():
|
||||
text += f"• <code>{lang}</code>: {lang_stats['active_count']} правил, "
|
||||
text += f"{lang_stats['content_length']} символов\n"
|
||||
if lang_stats['last_updated']:
|
||||
text += f" Обновлено: {lang_stats['last_updated'].strftime('%d.%m.%Y %H:%M')}\n"
|
||||
else:
|
||||
text += "ℹ️ Активных правил нет - используются правила по умолчанию"
|
||||
|
||||
await message.reply(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении статистики правил: {e}")
|
||||
await message.reply(
|
||||
f"❌ <b>Ошибка получения статистики</b>\n\n"
|
||||
f"Произошла ошибка: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def admin_commands_help(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
help_text = """
|
||||
🔧 <b>Доступные админские команды:</b>
|
||||
|
||||
<b>📋 Управление правилами:</b>
|
||||
• <code>/clear_rules</code> - очистить все правила
|
||||
• <code>/rules_stats</code> - статистика правил
|
||||
|
||||
<b>ℹ️ Справка:</b>
|
||||
• <code>/admin_help</code> - это сообщение
|
||||
|
||||
<b>📱 Панель управления:</b>
|
||||
Используйте кнопку "Админ панель" в главном меню для полного доступа ко всем функциям.
|
||||
|
||||
<b>⚠️ Важно:</b>
|
||||
Все команды логируются и требуют админских прав.
|
||||
"""
|
||||
|
||||
await message.reply(help_text)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_submenu,
|
||||
F.data == "admin_submenu_users"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promo_submenu,
|
||||
F.data == "admin_submenu_promo"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_communications_submenu,
|
||||
F.data == "admin_submenu_communications"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_settings_submenu,
|
||||
F.data == "admin_submenu_settings"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_system_submenu,
|
||||
F.data == "admin_submenu_system"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
clear_rules_command,
|
||||
Command("clear_rules")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
rules_stats_command,
|
||||
Command("rules_stats")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
admin_commands_help,
|
||||
Command("admin_help")
|
||||
)
|
||||
+459
-23
@@ -13,7 +13,8 @@ from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard
|
||||
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
|
||||
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
@@ -23,6 +24,71 @@ from app.utils.decorators import admin_required, error_handler
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
|
||||
types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
|
||||
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
|
||||
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
|
||||
types.InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list) -> types.InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
button_row = []
|
||||
if "balance" in selected_buttons:
|
||||
button_row.append(types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup"))
|
||||
|
||||
if "referrals" in selected_buttons:
|
||||
button_row.append(types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="menu_referrals"))
|
||||
|
||||
if "promocode" in selected_buttons:
|
||||
button_row.append(types.InlineKeyboardButton(text="🎫 Промокод", callback_data="menu_promocode"))
|
||||
|
||||
if len(button_row) > 2:
|
||||
keyboard.append(button_row[:2])
|
||||
if len(button_row) > 2:
|
||||
keyboard.append(button_row[2:])
|
||||
elif button_row:
|
||||
keyboard.append(button_row)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(text="🏠 На главную", callback_data="back_to_menu")])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_menu(
|
||||
@@ -107,13 +173,16 @@ async def show_messages_history(
|
||||
|
||||
message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text
|
||||
|
||||
import html
|
||||
message_preview = html.escape(message_preview)
|
||||
|
||||
text += f"""
|
||||
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||||
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
|
||||
🎯 Аудитория: {get_target_name(broadcast.target_type)}
|
||||
👤 Админ: {broadcast.admin_name}
|
||||
📝 Сообщение: <i>{message_preview}</i>
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
📝 Сообщение: {message_preview}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard
|
||||
@@ -121,7 +190,7 @@ async def show_messages_history(
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -260,15 +329,289 @@ async def process_broadcast_message(
|
||||
await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
|
||||
await state.update_data(broadcast_message=broadcast_text)
|
||||
|
||||
await message.answer(
|
||||
"🖼️ <b>Добавление медиафайла</b>\n\n"
|
||||
"Вы можете добавить к сообщению фото, видео или документ.\n"
|
||||
"Или пропустить этот шаг.\n\n"
|
||||
"Выберите тип медиа:",
|
||||
reply_markup=get_broadcast_media_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_media_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
if callback.data == "skip_media":
|
||||
await state.update_data(has_media=False)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
return
|
||||
|
||||
media_type = callback.data.replace('add_media_', '')
|
||||
|
||||
media_instructions = {
|
||||
"photo": "📷 Отправьте фотографию для рассылки:",
|
||||
"video": "🎥 Отправьте видео для рассылки:",
|
||||
"document": "📄 Отправьте документ для рассылки:"
|
||||
}
|
||||
|
||||
await state.update_data(
|
||||
media_type=media_type,
|
||||
waiting_for_media=True
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n"
|
||||
f"<i>Размер файла не должен превышать 50 МБ</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_media)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_broadcast_media(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
expected_type = data.get('media_type')
|
||||
|
||||
media_file_id = None
|
||||
media_type = None
|
||||
|
||||
if message.photo and expected_type == "photo":
|
||||
media_file_id = message.photo[-1].file_id
|
||||
media_type = "photo"
|
||||
elif message.video and expected_type == "video":
|
||||
media_file_id = message.video.file_id
|
||||
media_type = "video"
|
||||
elif message.document and expected_type == "document":
|
||||
media_file_id = message.document.file_id
|
||||
media_type = "document"
|
||||
else:
|
||||
await message.answer(
|
||||
f"❌ Пожалуйста, отправьте {expected_type} как указано в инструкции."
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
has_media=True,
|
||||
media_file_id=media_file_id,
|
||||
media_type=media_type,
|
||||
media_caption=message.caption
|
||||
)
|
||||
|
||||
await show_media_preview(message, db_user, state)
|
||||
|
||||
async def show_media_preview(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
media_type = data.get('media_type')
|
||||
|
||||
preview_text = f"🖼️ <b>Медиафайл добавлен</b>\n\n" \
|
||||
f"📎 <b>Тип:</b> {media_type}\n" \
|
||||
f"✅ Файл сохранен и готов к отправке\n\n" \
|
||||
f"Что делать дальше?"
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=get_media_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_media_confirmation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
action = callback.data
|
||||
|
||||
if action == "confirm_media":
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
elif action == "replace_media":
|
||||
data = await state.get_data()
|
||||
media_type = data.get('media_type', 'photo')
|
||||
await handle_media_selection(callback, db_user, state)
|
||||
elif action == "skip_media":
|
||||
await state.update_data(
|
||||
has_media=False,
|
||||
media_file_id=None,
|
||||
media_type=None,
|
||||
media_caption=None
|
||||
)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_change_media(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🖼️ <b>Изменение медиафайла</b>\n\n"
|
||||
"Выберите новый тип медиа:",
|
||||
reply_markup=get_broadcast_media_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
has_media = data.get('has_media', False)
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type = data.get('media_type', 'файл')
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type} добавлен"
|
||||
|
||||
text = f"""
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> - откроет методы пополнения
|
||||
🤝 <b>Рефералы</b> - откроет реферальную программу
|
||||
🎫 <b>Промокод</b> - откроет форму ввода промокода
|
||||
|
||||
Кнопка "🏠 На главную" добавляется автоматически внизу.{media_info}
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
|
||||
selected_buttons, has_media, db_user.language
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
text = """
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> - откроет методы пополнения
|
||||
🤝 <b>Рефералы</b> - откроет реферальную программу
|
||||
🎫 <b>Промокод</b> - откроет форму ввода промокода
|
||||
|
||||
Кнопка "🏠 На главную" добавляется автоматически внизу.
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
data = await state.get_data()
|
||||
if 'selected_buttons' not in data:
|
||||
data['selected_buttons'] = []
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=get_message_buttons_selector_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_button_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
button_type = callback.data.replace('btn_', '')
|
||||
data = await state.get_data()
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
|
||||
if button_type in selected_buttons:
|
||||
selected_buttons.remove(button_type)
|
||||
else:
|
||||
selected_buttons.append(button_type)
|
||||
|
||||
data['selected_buttons'] = selected_buttons
|
||||
await state.set_data(data)
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_button_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
has_media = data.get('has_media', False)
|
||||
media_type = data.get('media_type')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
target_display = get_target_display_name(target)
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type_names = {
|
||||
"photo": "Фотография",
|
||||
"video": "Видео",
|
||||
"document": "Документ"
|
||||
}
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
|
||||
|
||||
buttons_info = ""
|
||||
if selected_buttons:
|
||||
buttons_list = []
|
||||
if "balance" in selected_buttons:
|
||||
buttons_list.append("💰 Пополнить баланс")
|
||||
if "referrals" in selected_buttons:
|
||||
buttons_list.append("🤝 Рефералы")
|
||||
if "promocode" in selected_buttons:
|
||||
buttons_list.append("🎫 Промокод")
|
||||
buttons_info = f"\n📘 <b>Дополнительные кнопки:</b> {', '.join(buttons_list)}"
|
||||
|
||||
buttons_info += "\n🏠 <b>Основная кнопка:</b> На главную"
|
||||
|
||||
preview_text = f"""
|
||||
📨 <b>Предварительный просмотр рассылки</b>
|
||||
|
||||
@@ -276,7 +619,9 @@ async def process_broadcast_message(
|
||||
👥 <b>Получателей:</b> {user_count}
|
||||
|
||||
📝 <b>Сообщение:</b>
|
||||
{broadcast_text}
|
||||
{message_text}{media_info}
|
||||
|
||||
{buttons_info}
|
||||
|
||||
Подтвердить отправку?
|
||||
"""
|
||||
@@ -284,16 +629,59 @@ async def process_broadcast_message(
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
types.InlineKeyboardButton(text="📘 Изменить кнопки", callback_data="edit_buttons")
|
||||
]
|
||||
]
|
||||
|
||||
await message.answer(
|
||||
if has_media:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
text = """
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> - откроет методы пополнения
|
||||
🤝 <b>Рефералы</b> - откроет реферальную программу
|
||||
🎫 <b>Промокод</b> - откроет форму ввода промокода
|
||||
|
||||
Кнопка "🏠 На главную" добавляется автоматически внизу.
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
data = await state.get_data()
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -307,6 +695,11 @@ async def confirm_broadcast(
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
selected_buttons = data.get('selected_buttons', [])
|
||||
has_media = data.get('has_media', False)
|
||||
media_type = data.get('media_type')
|
||||
media_file_id = data.get('media_file_id')
|
||||
media_caption = data.get('media_caption')
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 Начинаю рассылку...\n\n"
|
||||
@@ -323,6 +716,10 @@ async def confirm_broadcast(
|
||||
broadcast_history = BroadcastHistory(
|
||||
target_type=target,
|
||||
message_text=message_text,
|
||||
has_media=has_media,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
total_count=len(users),
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
@@ -337,13 +734,42 @@ async def confirm_broadcast(
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons) if selected_buttons else None
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
@@ -359,6 +785,10 @@ async def confirm_broadcast(
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type}"
|
||||
|
||||
result_text = f"""
|
||||
✅ <b>Рассылка завершена!</b>
|
||||
|
||||
@@ -366,7 +796,7 @@ async def confirm_broadcast(
|
||||
- Отправлено: {sent_count}
|
||||
- Не доставлено: {failed_count}
|
||||
- Всего пользователей: {len(users)}
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info}
|
||||
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
@@ -380,7 +810,7 @@ async def confirm_broadcast(
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)}")
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})")
|
||||
|
||||
|
||||
async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
@@ -413,7 +843,6 @@ async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
|
||||
|
||||
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
"""Получение пользователей по настраиваемым критериям"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
@@ -462,7 +891,6 @@ async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
"""Получение статистики пользователей для отображения"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
@@ -557,4 +985,12 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
|
||||
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
|
||||
|
||||
dp.callback_query.register(toggle_button_selection, F.data.startswith("btn_"))
|
||||
dp.callback_query.register(confirm_button_selection, F.data == "buttons_confirm")
|
||||
dp.callback_query.register(show_button_selector_callback, F.data == "edit_buttons")
|
||||
dp.callback_query.register(handle_media_selection, F.data.startswith("add_media_"))
|
||||
dp.callback_query.register(handle_media_selection, F.data == "skip_media")
|
||||
dp.callback_query.register(handle_media_confirmation, F.data.in_(["confirm_media", "replace_media"]))
|
||||
dp.callback_query.register(handle_change_media, F.data == "change_media")
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -19,7 +20,6 @@ router = Router()
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
"""Главное меню мониторинга"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
@@ -127,50 +127,57 @@ async def force_check_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_logs")
|
||||
@router.callback_query(F.data.startswith("admin_mon_logs"))
|
||||
@admin_required
|
||||
async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
page = 1
|
||||
if "_page_" in callback.data:
|
||||
page = int(callback.data.split("_page_")[1])
|
||||
|
||||
async for db in get_db():
|
||||
logs = await monitoring_service.get_monitoring_logs(db, limit=15)
|
||||
all_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
|
||||
|
||||
if not logs:
|
||||
text = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
|
||||
else:
|
||||
text = "📝 <b>Последние логи мониторинга:</b>\n\n"
|
||||
if not all_logs:
|
||||
text = "📋 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполнила проверки."
|
||||
keyboard = get_monitoring_logs_back_keyboard()
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
return
|
||||
|
||||
per_page = 8
|
||||
paginated_logs = paginate_list(all_logs, page=page, per_page=per_page)
|
||||
|
||||
text = f"📋 <b>Логи мониторинга</b> (стр. {page}/{paginated_logs.total_pages})\n\n"
|
||||
|
||||
for log in paginated_logs.items:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
for log in logs:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
|
||||
message = log['message']
|
||||
if len(message) > 60:
|
||||
message = message[:60] + "..."
|
||||
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
if len(text) > 3500:
|
||||
text += "...\n\n<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
|
||||
break
|
||||
message = log['message']
|
||||
if len(message) > 45:
|
||||
message = message[:45] + "..."
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
total_success = sum(1 for log in all_logs if log['is_success'])
|
||||
total_failed = len(all_logs) - total_success
|
||||
success_rate = round(total_success / len(all_logs) * 100, 1) if all_logs else 0
|
||||
|
||||
text += f"📊 <b>Общая статистика:</b>\n"
|
||||
text += f"• Всего событий: {len(all_logs)}\n"
|
||||
text += f"• Успешных: {total_success}\n"
|
||||
text += f"• Ошибок: {total_failed}\n"
|
||||
text += f"• Успешность: {success_rate}%"
|
||||
|
||||
keyboard = get_monitoring_logs_keyboard(page, paginated_logs.total_pages)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True)
|
||||
await callback.answer("❌ Ошибка получения логов", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_clear_logs")
|
||||
@@ -178,12 +185,12 @@ async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
async def clear_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=7)
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=0)
|
||||
|
||||
if deleted_count > 0:
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов")
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} записей логов")
|
||||
else:
|
||||
await callback.answer("ℹ️ Нет старых логов для удаления")
|
||||
await callback.answer("ℹ️ Логи уже пусты")
|
||||
|
||||
await monitoring_logs_callback(callback)
|
||||
break
|
||||
@@ -196,7 +203,6 @@ async def clear_logs_callback(callback: CallbackQuery):
|
||||
@router.callback_query(F.data == "admin_mon_test_notifications")
|
||||
@admin_required
|
||||
async def test_notifications_callback(callback: CallbackQuery):
|
||||
"""Тест системы уведомлений"""
|
||||
try:
|
||||
test_message = f"""
|
||||
🧪 <b>Тестовое уведомление системы мониторинга</b>
|
||||
@@ -280,10 +286,62 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_monitoring_logs_back_keyboard():
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🔍 Фильтры", callback_data="admin_mon_logs_filters")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Очистить логи", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
|
||||
@router.message(Command("monitoring"))
|
||||
@admin_required
|
||||
async def monitoring_command(message: Message):
|
||||
"""Команда /monitoring для быстрого доступа"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
@@ -309,5 +367,4 @@ async def monitoring_command(message: Message):
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Регистрация обработчиков мониторинга"""
|
||||
dp.include_router(router)
|
||||
+223
-42
@@ -8,7 +8,8 @@ from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules, clear_all_rules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +32,9 @@ async def show_rules_management(
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="ℹ️ Помощь по HTML", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -48,16 +51,33 @@ async def view_current_rules(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
is_valid, error_msg = validate_html_tags(current_rules)
|
||||
warning = ""
|
||||
if not is_valid:
|
||||
warning = f"\n\n⚠️ <b>Внимание:</b> В правилах найдена ошибка HTML: {error_msg}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}{warning}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе правил: {e}")
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при загрузке правил. Возможно, в тексте есть некорректные HTML теги.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -68,20 +88,33 @@ async def start_edit_rules(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '')
|
||||
|
||||
text = (
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n<code>{preview}</code>\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка. Все теги будут проверены перед сохранением.</i>\n\n"
|
||||
"💡 <b>Совет:</b> Нажмите /html_help для просмотра поддерживаемых тегов"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при начале редактирования правил: {e}")
|
||||
await callback.answer("❌ Ошибка при загрузке правил для редактирования", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -98,19 +131,61 @@ async def process_rules_edit(
|
||||
await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
is_valid, error_msg = validate_html_tags(new_rules)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ <b>Ошибка в HTML разметке:</b>\n{error_msg}\n\n"
|
||||
f"Пожалуйста, исправьте ошибки и отправьте текст заново.\n\n"
|
||||
f"💡 Используйте /html_help для просмотра правильного синтаксиса",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
try:
|
||||
preview_text = f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
preview_text += f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
preview_text += f"Сохранить изменения?"
|
||||
|
||||
if len(preview_text) > 4000:
|
||||
preview_text = (
|
||||
"📋 <b>Предварительный просмотр новых правил:</b>\n\n"
|
||||
f"{new_rules[:500]}...\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Текст правил: {len(new_rules)} символов\n"
|
||||
f"Сохранить изменения?"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе превью правил: {e}")
|
||||
await message.answer(
|
||||
"⚠️ <b>Подтверждение сохранения правил</b>\n\n"
|
||||
f"Новые правила готовы к сохранению ({len(new_rules)} символов).\n"
|
||||
f"HTML теги проверены и корректны.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -128,6 +203,20 @@ async def save_rules(
|
||||
await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True)
|
||||
return
|
||||
|
||||
is_valid, error_msg = validate_html_tags(new_rules)
|
||||
if not is_valid:
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>Ошибка при сохранении:</b>\n{error_msg}\n\n"
|
||||
f"Правила не были сохранены из-за ошибок в HTML разметке.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
await create_or_update_rules(
|
||||
db=db,
|
||||
@@ -142,10 +231,14 @@ async def save_rules(
|
||||
await refresh_rules_cache(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила сервиса обновлены!</b>\n\n"
|
||||
"Новые правила сохранены в базе данных и будут показываться пользователям.\n\n"
|
||||
"Кеш правил очищен и обновлен.",
|
||||
"✅ <b>Правила сервиса успешно обновлены!</b>\n\n"
|
||||
"✓ Новые правила сохранены в базе данных\n"
|
||||
"✓ HTML теги проверены и корректны\n"
|
||||
"✓ Кеш правил очищен и обновлен\n"
|
||||
"✓ Правила будут показываться пользователям\n\n"
|
||||
f"📊 Размер текста: {len(new_rules)} символов",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👀 Просмотреть", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
@@ -156,7 +249,90 @@ async def save_rules(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения правил: {e}")
|
||||
await callback.answer("❌ Ошибка сохранения правил", show_alert=True)
|
||||
await callback.message.edit_text(
|
||||
"❌ <b>Ошибка при сохранении правил</b>\n\n"
|
||||
"Произошла ошибка при записи в базу данных. Попробуйте еще раз.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_save_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_rules_confirmation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🗑️ <b>Очистка правил сервиса</b>\n\n"
|
||||
"⚠️ <b>ВНИМАНИЕ!</b> Вы собираетесь полностью удалить все правила сервиса.\n\n"
|
||||
"После очистки пользователи будут видеть стандартные правила по умолчанию.\n\n"
|
||||
"Это действие нельзя отменить. Продолжить?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_confirm_clear_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_clear_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
await clear_all_rules(db, db_user.language)
|
||||
|
||||
from app.localization.texts import clear_rules_cache
|
||||
clear_rules_cache()
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила успешно очищены!</b>\n\n"
|
||||
"✓ Все пользовательские правила удалены\n"
|
||||
"✓ Теперь используются стандартные правила\n"
|
||||
"✓ Кеш правил очищен\n\n"
|
||||
"Пользователи будут видеть правила по умолчанию.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📝 Создать новые", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Посмотреть текущие", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
logger.info(f"Правила очищены администратором {db_user.telegram_id}")
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке правил: {e}")
|
||||
await callback.answer("❌ Ошибка при очистке правил", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
help_text = get_html_help_text()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"ℹ️ <b>Справка по HTML форматированию</b>\n\n{help_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
@@ -165,4 +341,9 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules")
|
||||
dp.callback_query.register(save_rules, F.data == "admin_save_rules")
|
||||
|
||||
dp.callback_query.register(clear_rules_confirmation, F.data == "admin_clear_rules")
|
||||
dp.callback_query.register(confirm_clear_rules, F.data == "admin_confirm_clear_rules")
|
||||
|
||||
dp.callback_query.register(show_html_help, F.data == "admin_rules_help")
|
||||
|
||||
dp.message.register(process_rules_edit, AdminStates.editing_rules_page)
|
||||
+89
-30
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -78,26 +78,52 @@ async def show_users_list(
|
||||
return
|
||||
|
||||
text = f"👥 <b>Список пользователей</b> (стр. {page}/{users_data['total_pages']})\n\n"
|
||||
|
||||
for user in users_data["users"]:
|
||||
status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "❌"
|
||||
subscription_info = ""
|
||||
|
||||
if user.subscription:
|
||||
if user.subscription.is_trial:
|
||||
subscription_info = "🎁"
|
||||
elif user.subscription.is_active:
|
||||
subscription_info = "💎"
|
||||
else:
|
||||
subscription_info = "⏰"
|
||||
|
||||
text += f"{status_emoji} {subscription_info} <b>{user.full_name}</b>\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
text += f"💰 {settings.format_price(user.balance_kopeks)}\n"
|
||||
text += f"📅 {format_time_ago(user.created_at)}\n\n"
|
||||
text += "Нажмите на пользователя для управления:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in users_data["users"]:
|
||||
if user.status == UserStatus.ACTIVE.value:
|
||||
status_emoji = "✅"
|
||||
elif user.status == UserStatus.BLOCKED.value:
|
||||
status_emoji = "🚫"
|
||||
else:
|
||||
status_emoji = "🗑️"
|
||||
|
||||
subscription_emoji = ""
|
||||
if user.subscription:
|
||||
if user.subscription.is_trial:
|
||||
subscription_emoji = "🎁"
|
||||
elif user.subscription.is_active:
|
||||
subscription_emoji = "💎"
|
||||
else:
|
||||
subscription_emoji = "⏰"
|
||||
else:
|
||||
subscription_emoji = "❌"
|
||||
|
||||
button_text = f"{status_emoji} {subscription_emoji} {user.full_name}"
|
||||
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
|
||||
|
||||
button_text += f" | 📅 {format_time_ago(user.created_at)}"
|
||||
|
||||
if len(button_text) > 60:
|
||||
short_name = user.full_name
|
||||
if len(short_name) > 20:
|
||||
short_name = short_name[:17] + "..."
|
||||
|
||||
button_text = f"{status_emoji} {subscription_emoji} {short_name}"
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if users_data["total_pages"] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data["current_page"],
|
||||
@@ -474,27 +500,45 @@ async def process_user_search(
|
||||
return
|
||||
|
||||
text = f"🔍 <b>Результаты поиска:</b> '{query}'\n\n"
|
||||
text += "Выберите пользователя:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in search_results["users"]:
|
||||
status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "❌"
|
||||
subscription_info = ""
|
||||
if user.status == UserStatus.ACTIVE.value:
|
||||
status_emoji = "✅"
|
||||
elif user.status == UserStatus.BLOCKED.value:
|
||||
status_emoji = "🚫"
|
||||
else:
|
||||
status_emoji = "🗑️"
|
||||
|
||||
subscription_emoji = ""
|
||||
if user.subscription:
|
||||
if user.subscription.is_trial:
|
||||
subscription_info = "🎁"
|
||||
subscription_emoji = "🎁"
|
||||
elif user.subscription.is_active:
|
||||
subscription_info = "💎"
|
||||
subscription_emoji = "💎"
|
||||
else:
|
||||
subscription_info = "⏰"
|
||||
subscription_emoji = "⏰"
|
||||
else:
|
||||
subscription_emoji = "❌"
|
||||
|
||||
text += f"{status_emoji} {subscription_info} <b>{user.full_name}</b>\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
text += f"💰 {settings.format_price(user.balance_kopeks)}\n\n"
|
||||
button_text = f"{status_emoji} {subscription_emoji} {user.full_name}"
|
||||
|
||||
button_text += f" | 🆔 {user.telegram_id}"
|
||||
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
|
||||
|
||||
if len(button_text) > 60:
|
||||
short_name = user.full_name
|
||||
if len(short_name) > 15:
|
||||
short_name = short_name[:12] + "..."
|
||||
button_text = f"{status_emoji} {subscription_emoji} {short_name} | 🆔 {user.telegram_id}"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"👤 {user.full_name}",
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
@@ -1600,7 +1644,12 @@ async def toggle_user_server(
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
active_internal_squads=current_squads
|
||||
active_internal_squads=current_squads,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлены серверы в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
@@ -1979,7 +2028,12 @@ async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, adm
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
hwid_device_limit=devices
|
||||
hwid_device_limit=devices,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит устройств в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
@@ -2017,7 +2071,12 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int,
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит трафика в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
|
||||
@@ -12,7 +12,9 @@ from app.database.crud.welcome_text import (
|
||||
get_active_welcome_text,
|
||||
set_welcome_text,
|
||||
get_current_welcome_text_or_default,
|
||||
get_available_placeholders
|
||||
get_available_placeholders,
|
||||
get_current_welcome_text_settings,
|
||||
toggle_welcome_text_status
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -42,11 +44,41 @@ async def show_welcome_text_panel(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
"👋 Управление приветственным текстом\n\n"
|
||||
"Здесь вы можете изменить текст, который показывается новым пользователям после регистрации.\n\n"
|
||||
"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language)
|
||||
f"👋 Управление приветственным текстом\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
|
||||
f"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
new_status = await toggle_welcome_text_status(db, db_user.id)
|
||||
|
||||
status_emoji = "🟢" if new_status else "🔴"
|
||||
status_text = "включено" if new_status else "отключено"
|
||||
action_text = "включены" if new_status else "отключены"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👋 Управление приветственным текстом\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"✅ Приветственные сообщения {action_text}!\n\n"
|
||||
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
|
||||
f"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, new_status),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -57,22 +89,27 @@ async def show_current_welcome_text(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_text = await get_active_welcome_text(db)
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
current_text = settings['text']
|
||||
is_enabled = settings['is_enabled']
|
||||
|
||||
if not current_text:
|
||||
current_text = await get_current_welcome_text_or_default()
|
||||
if not settings['id']:
|
||||
status = "📝 Используется стандартный текст:"
|
||||
else:
|
||||
status = "📝 Текущий приветственный текст:"
|
||||
|
||||
status_emoji = "🟢" if is_enabled else "🔴"
|
||||
status_text = "включено" if is_enabled else "отключено"
|
||||
|
||||
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_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"{status}\n\n"
|
||||
f"<code>{current_text}</code>\n\n"
|
||||
f"💡 Доступные плейсхолдеры:\n{placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, is_enabled),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -84,6 +121,7 @@ async def show_placeholders_help(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
@@ -99,7 +137,7 @@ async def show_placeholders_help(
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -111,11 +149,12 @@ async def show_formatting_help(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
formatting_info = get_telegram_formatting_info()
|
||||
|
||||
await callback.message.edit_text(
|
||||
formatting_info,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -128,10 +167,8 @@ async def start_edit_welcome_text(
|
||||
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()
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
current_text = settings['text']
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
|
||||
@@ -169,21 +206,27 @@ async def process_welcome_text_edit(
|
||||
success = await set_welcome_text(db, new_text, db_user.id)
|
||||
|
||||
if success:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
|
||||
|
||||
await message.answer(
|
||||
f"✅ Приветственный текст успешно обновлен!\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Новый текст:\n"
|
||||
f"<code>{new_text}</code>\n\n"
|
||||
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
await message.answer(
|
||||
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language)
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
@@ -199,18 +242,24 @@ async def reset_welcome_text(
|
||||
success = await set_welcome_text(db, default_text, db_user.id)
|
||||
|
||||
if success:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Приветственный текст сброшен на стандартный!\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\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),
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language)
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@@ -232,13 +281,25 @@ async def show_preview_welcome_text(
|
||||
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"
|
||||
)
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
|
||||
if preview_text:
|
||||
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, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"🔴 Приветственные сообщения отключены.\n"
|
||||
f"Новые пользователи не будут получать приветственный текст после регистрации.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
def register_welcome_text_handlers(dp: Dispatcher):
|
||||
@@ -247,6 +308,11 @@ def register_welcome_text_handlers(dp: Dispatcher):
|
||||
F.data == "welcome_text_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_welcome_text,
|
||||
F.data == "toggle_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_current_welcome_text,
|
||||
F.data == "show_welcome_text"
|
||||
|
||||
@@ -42,9 +42,10 @@ async def show_main_menu(
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -119,7 +120,8 @@ async def handle_back_to_menu(
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,16 +90,45 @@ async def handle_successful_payment(
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
f"⭐ Потрачено звезд: {payment.total_amount}\n"
|
||||
f"💰 Зачислено на баланс: {int(rubles_amount)} ₽\n"
|
||||
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Спасибо за пополнение! 🚀",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}₽"
|
||||
|
||||
+162
-38
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -147,7 +148,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
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
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -224,13 +226,36 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
logger.info(f"💾 Установлен русский язык по умолчанию")
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
|
||||
if data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
await complete_registration(message, state, db)
|
||||
else:
|
||||
try:
|
||||
await message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info("🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration(message, state, db)
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"📋 Правила отправлены")
|
||||
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Установлено состояние: {current_state}")
|
||||
@@ -274,25 +299,29 @@ async def process_rules_accept(
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
if settings.SKIP_REFERRAL_CODE:
|
||||
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
||||
@@ -390,6 +419,8 @@ async def complete_registration_from_callback(
|
||||
):
|
||||
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
|
||||
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
@@ -400,6 +431,8 @@ async def complete_registration_from_callback(
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await callback.message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
@@ -417,7 +450,8 @@ async def complete_registration_from_callback(
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -455,7 +489,7 @@ async def complete_registration_from_callback(
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
|
||||
@@ -475,6 +509,7 @@ async def complete_registration_from_callback(
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
else:
|
||||
logger.info(f"🔄 Обновляем существующего пользователя {callback.from_user.id}")
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
@@ -487,7 +522,7 @@ async def complete_registration_from_callback(
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
user = existing_user
|
||||
|
||||
if referrer_id:
|
||||
@@ -500,20 +535,50 @@ 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 = await get_welcome_text_for_user(db, user_name)
|
||||
offer_text = await get_welcome_text_for_user(db, callback.from_user)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке предложения триала: {e}")
|
||||
if offer_text:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
)
|
||||
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
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,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню: {e}")
|
||||
await callback.message.answer(f"Добро пожаловать, {user.full_name}!")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
|
||||
|
||||
|
||||
async def complete_registration(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
@@ -531,6 +596,8 @@ async def complete_registration(
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
@@ -548,7 +615,8 @@ async def complete_registration(
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=existing_user.balance_kopeks
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -586,7 +654,7 @@ async def complete_registration(
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
|
||||
@@ -606,6 +674,7 @@ async def complete_registration(
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
else:
|
||||
logger.info(f"🔄 Обновляем существующего пользователя {message.from_user.id}")
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
@@ -618,7 +687,7 @@ async def complete_registration(
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
user = existing_user
|
||||
|
||||
if referrer_id:
|
||||
@@ -631,16 +700,46 @@ async def complete_registration(
|
||||
await state.clear()
|
||||
|
||||
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(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке предложения триала: {e}")
|
||||
if offer_text:
|
||||
try:
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
)
|
||||
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
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,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню: {e}")
|
||||
await message.answer(f"Добро пожаловать, {user.full_name}!")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
|
||||
|
||||
@@ -751,6 +850,25 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
|
||||
return base_text
|
||||
|
||||
|
||||
async def required_sub_channel_check(
|
||||
query: types.CallbackQuery,
|
||||
bot: Bot,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
db_user=None
|
||||
):
|
||||
chat_member = await bot.get_chat_member(
|
||||
chat_id=settings.CHANNEL_SUB_ID,
|
||||
user_id=query.from_user.id
|
||||
)
|
||||
if chat_member.status not in [ChatMemberStatus.MEMBER]:
|
||||
return await query.answer("❌ Вы не подписались на канал!", show_alert=True)
|
||||
await query.answer("✅ Спасибо за подписку", show_alert=True)
|
||||
await query.message.delete()
|
||||
await cmd_start(query.message, state, db, db_user)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===")
|
||||
@@ -789,6 +907,12 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован handle_potential_referral_code")
|
||||
|
||||
dp.callback_query.register(
|
||||
required_sub_channel_check,
|
||||
F.data.in_(["sub_channel_check"])
|
||||
)
|
||||
logger.info("✅ Зарегистрирован required_sub_channel_check")
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ async def show_subscription_info(
|
||||
message += "</blockquote>"
|
||||
|
||||
if hasattr(subscription, 'subscription_url') and subscription.subscription_url:
|
||||
if actual_status in ['trial_active', 'paid_active']:
|
||||
if actual_status in ['trial_active', 'paid_active'] and not settings.HIDE_SUBSCRIPTION_LINK:
|
||||
message += f"\n\n🔗 <b>Ссылка для подключения:</b>\n<code>{subscription.subscription_url}</code>"
|
||||
message += f"\n\n📱 Скопируйте ссылку и добавьте в ваше VPN приложение"
|
||||
|
||||
@@ -626,7 +626,7 @@ async def apply_countries_changes(
|
||||
):
|
||||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||||
|
||||
logger.info(f"🔍 Применение изменений стран")
|
||||
logger.info(f"🔧 Применение изменений стран")
|
||||
|
||||
data = await state.get_data()
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -642,7 +642,7 @@ async def apply_countries_changes(
|
||||
await callback.answer("⚠️ Изменения не обнаружены", show_alert=True)
|
||||
return
|
||||
|
||||
logger.info(f"🔍 Добавлено: {added}, Удалено: {removed}")
|
||||
logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}")
|
||||
|
||||
countries = await _get_available_countries()
|
||||
|
||||
@@ -674,7 +674,7 @@ async def apply_countries_changes(
|
||||
|
||||
if total_cost > 0 and db_user.balance_kopeks < total_cost:
|
||||
await callback.answer(
|
||||
f"⚠ Недостаточно средств!\nТребуется: {texts.format_price(total_cost)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}",
|
||||
f"⚠️ Недостаточно средств!\nТребуется: {texts.format_price(total_cost)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}",
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
@@ -686,7 +686,7 @@ async def apply_countries_changes(
|
||||
f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
|
||||
)
|
||||
if not success:
|
||||
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
|
||||
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
await create_transaction(
|
||||
@@ -718,6 +718,15 @@ async def apply_countries_changes(
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "servers", current_countries, selected_countries, total_cost
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}")
|
||||
|
||||
success_text = "✅ <b>Страны успешно обновлены!</b>\n\n"
|
||||
|
||||
if added_names:
|
||||
@@ -732,7 +741,7 @@ async def apply_countries_changes(
|
||||
success_text += "\n".join(f"• {name}" for name in removed_names)
|
||||
success_text += "\nℹ️ Повторное подключение будет платным\n"
|
||||
|
||||
success_text += f"\n🌍 <b>Активных стран:</b> {len(selected_countries)}"
|
||||
success_text += f"\n🌐 <b>Активных стран:</b> {len(selected_countries)}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
@@ -744,7 +753,7 @@ async def apply_countries_changes(
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost/100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка применения изменений: {e}")
|
||||
logger.error(f"⚠️ Ошибка применения изменений: {e}")
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
@@ -930,6 +939,15 @@ async def execute_change_devices(
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "devices", current_devices, new_devices_count, price
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении устройств: {e}")
|
||||
|
||||
if new_devices_count > current_devices:
|
||||
success_text = f"✅ Количество устройств увеличено!\n\n"
|
||||
success_text += f"📱 Было: {current_devices} → Стало: {new_devices_count}\n"
|
||||
@@ -3455,6 +3473,15 @@ async def execute_switch_traffic(
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "traffic", current_traffic, new_traffic_gb, price_difference
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}")
|
||||
|
||||
if new_traffic_gb > current_traffic:
|
||||
success_text = f"✅ Лимит трафика увеличен!\n\n"
|
||||
success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
|
||||
|
||||
+339
-34
@@ -7,38 +7,92 @@ from app.localization.texts import get_texts
|
||||
def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")],
|
||||
[InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")],
|
||||
[InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")],
|
||||
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")],
|
||||
[InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_USERS, callback_data="admin_users"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_REFERRALS, callback_data="admin_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave")
|
||||
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics"),
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"),
|
||||
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel"),
|
||||
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel")
|
||||
|
||||
],
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновления", callback_data="admin_updates"),
|
||||
InlineKeyboardButton(text="📄 Обновления", callback_data="admin_updates"),
|
||||
InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -54,7 +108,7 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -73,7 +127,7 @@ def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -88,7 +142,7 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_promo_general_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_promo")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -120,7 +174,7 @@ def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="📋 История", callback_data="admin_msg_history")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_communications")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -139,7 +193,7 @@ def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_mon_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -152,13 +206,13 @@ def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_rw_sync"),
|
||||
InlineKeyboardButton(text="🌍 Управление сквадами", callback_data="admin_rw_squads")
|
||||
InlineKeyboardButton(text="🌐 Управление сквадами", callback_data="admin_rw_squads")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📈 Трафик", callback_data="admin_rw_traffic")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -177,7 +231,7 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_promo")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -320,8 +374,8 @@ def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="⚡ Активные сегодня", callback_data="criteria_active_today")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
|
||||
InlineKeyboardButton(text="👤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
|
||||
InlineKeyboardButton(text="💤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
|
||||
InlineKeyboardButton(text="💤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🤝 Через рефералов", callback_data="criteria_referrals"),
|
||||
@@ -486,7 +540,7 @@ def get_monitoring_keyboard() -> InlineKeyboardMarkup:
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"),
|
||||
InlineKeyboardButton(text="📝 Логи", callback_data="admin_mon_logs")
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"),
|
||||
@@ -508,6 +562,170 @@ def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_logs_navigation_keyboard(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
has_logs: bool = True
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page_info"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
management_row = []
|
||||
|
||||
if has_logs:
|
||||
management_row.extend([
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
])
|
||||
else:
|
||||
management_row.append(
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs")
|
||||
)
|
||||
|
||||
keyboard.append(management_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад к мониторингу", callback_data="admin_monitoring")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_log_detail_keyboard(log_id: int, current_page: int = 1) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить этот лог",
|
||||
callback_data=f"admin_mon_delete_log_{log_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ К списку логов",
|
||||
callback_data=f"admin_mon_logs_page_{current_page}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_monitoring_clear_confirm_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_mon_clear_logs_confirm"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Очистить ВСЕ логи", callback_data="admin_mon_clear_all_logs")
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_status_keyboard(
|
||||
is_running: bool,
|
||||
last_check_ago_minutes: int = 0
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
control_row = []
|
||||
if is_running:
|
||||
control_row.extend([
|
||||
InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop"),
|
||||
InlineKeyboardButton(text="🔄 Перезапустить", callback_data="admin_mon_restart")
|
||||
])
|
||||
else:
|
||||
control_row.append(
|
||||
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start")
|
||||
)
|
||||
|
||||
keyboard.append(control_row)
|
||||
|
||||
monitoring_row = []
|
||||
|
||||
if not is_running or last_check_ago_minutes > 10:
|
||||
monitoring_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⚡ Срочная проверка",
|
||||
callback_data="admin_mon_force_check"
|
||||
)
|
||||
)
|
||||
else:
|
||||
monitoring_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Проверить сейчас",
|
||||
callback_data="admin_mon_force_check"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(monitoring_row)
|
||||
|
||||
info_row = [
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
|
||||
]
|
||||
keyboard.append(info_row)
|
||||
|
||||
test_row = [
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications")
|
||||
]
|
||||
keyboard.append(test_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_monitoring_settings_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="⏱️ Интервал проверки", callback_data="admin_mon_set_interval"),
|
||||
InlineKeyboardButton(text="🔔 Уведомления", callback_data="admin_mon_toggle_notifications")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💳 Настройки автооплаты", callback_data="admin_mon_autopay_settings"),
|
||||
InlineKeyboardButton(text="🧹 Автоочистка логов", callback_data="admin_mon_auto_cleanup")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К мониторингу", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_log_type_filter_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Успешные", callback_data="admin_mon_logs_filter_success"),
|
||||
InlineKeyboardButton(text="❌ Ошибки", callback_data="admin_mon_logs_filter_error")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Циклы мониторинга", callback_data="admin_mon_logs_filter_cycle"),
|
||||
InlineKeyboardButton(text="💳 Автооплаты", callback_data="admin_mon_logs_filter_autopay")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Все логи", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -651,8 +869,8 @@ def get_maintenance_keyboard(
|
||||
callback_data="maintenance_panel"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="◀️ Назад",
|
||||
callback_data="admin_panel"
|
||||
text="⬅️ Назад",
|
||||
callback_data="admin_submenu_settings"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -666,19 +884,106 @@ 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=[
|
||||
|
||||
def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) -> InlineKeyboardMarkup:
|
||||
|
||||
toggle_text = "🔴 Отключить" if is_enabled else "🟢 Включить"
|
||||
toggle_callback = "toggle_welcome_text"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=toggle_text, callback_data=toggle_callback)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📝 Изменить текст", callback_data="edit_welcome_text"),
|
||||
InlineKeyboardButton(text="👁️ Показать текущий", callback_data="show_welcome_text")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👁️ Предпросмотр", callback_data="preview_welcome_text"),
|
||||
InlineKeyboardButton(text="🔄 Сбросить", callback_data="reset_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")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_communications")
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
|
||||
InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_broadcast_media_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📷 Добавить фото", callback_data="add_media_photo"),
|
||||
InlineKeyboardButton(text="🎥 Добавить видео", callback_data="add_media_video")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📄 Добавить документ", callback_data="add_media_document"),
|
||||
InlineKeyboardButton(text="⏭️ Пропустить медиа", callback_data="skip_media")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_media_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Использовать это медиа", callback_data="confirm_media"),
|
||||
InlineKeyboardButton(text="🔄 Заменить медиа", callback_data="replace_media")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⏭️ Без медиа", callback_data="skip_media"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: list, has_media: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
|
||||
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
|
||||
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
|
||||
InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
|
||||
]
|
||||
]
|
||||
|
||||
if has_media:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
+56
-8
@@ -21,12 +21,28 @@ def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
]
|
||||
])
|
||||
|
||||
def get_channel_sub_keyboard(channel_link: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подписаться", url=channel_link
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Я подписался", callback_data="sub_channel_check"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_post_registration_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🚀 Подключиться бесплатно 🚀", callback_data="menu_trial"
|
||||
text="🚀 Подключиться бесплатно 🚀", callback_data="trial_activate"
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text="Пропустить ➡️", callback_data="back_to_menu")],
|
||||
@@ -39,7 +55,8 @@ def get_main_menu_keyboard(
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
subscription_is_active: bool = False,
|
||||
balance_kopeks: int = 0
|
||||
balance_kopeks: int = 0,
|
||||
subscription=None
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -51,16 +68,47 @@ def get_main_menu_keyboard(
|
||||
else:
|
||||
balance_button_text = f"💰 Баланс: {texts.format_price(balance_kopeks)}"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
]
|
||||
]
|
||||
|
||||
keyboard = []
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
if connect_mode == "miniapp_subscription":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
web_app=types.WebAppInfo(url=subscription.subscription_url)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
url=subscription.subscription_url
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
callback_data="subscription_connect"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
|
||||
+41
-17
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
@@ -8,11 +9,31 @@ from aiogram.fsm.context import FSMContext
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.states import RegistrationStates
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _refresh_remnawave_description(
|
||||
remnawave_uuid: str,
|
||||
description: str,
|
||||
telegram_id: int
|
||||
) -> None:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(uuid=remnawave_uuid, description=description)
|
||||
logger.info(
|
||||
f"✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave"
|
||||
)
|
||||
except Exception as remnawave_error:
|
||||
logger.error(
|
||||
f"❌ [Middleware] Ошибка обновления RemnaWave для {telegram_id}: {remnawave_error}"
|
||||
)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
@@ -21,6 +42,8 @@ class AuthMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
|
||||
|
||||
user: TgUser = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
@@ -42,23 +65,10 @@ class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
is_registration_process = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (current_state in registration_states)
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
)
|
||||
)
|
||||
is_reg_process = is_registration_process(event, current_state)
|
||||
|
||||
if is_registration_process:
|
||||
if is_reg_process:
|
||||
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
data['db'] = db
|
||||
data['db_user'] = None
|
||||
@@ -151,11 +161,25 @@ class AuthMiddleware(BaseMiddleware):
|
||||
profile_updated = True
|
||||
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
if profile_updated:
|
||||
db_user.updated_at = datetime.utcnow()
|
||||
logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware")
|
||||
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
description = settings.format_remnawave_user_description(
|
||||
full_name=db_user.full_name,
|
||||
username=db_user.username,
|
||||
telegram_id=db_user.telegram_id
|
||||
)
|
||||
asyncio.create_task(
|
||||
_refresh_remnawave_description(
|
||||
remnawave_uuid=db_user.remnawave_uuid,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
data['db'] = db
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
|
||||
from app.config import settings
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
def __init__(self):
|
||||
self.BAD_MEMBER_STATUS = ("left", "kicked")
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
telegram_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
telegram_id = event.from_user.id
|
||||
elif isinstance(event, Update):
|
||||
if event.message:
|
||||
telegram_id = event.message.from_user.id
|
||||
elif event.callback_query:
|
||||
telegram_id = event.callback_query.from_user.id
|
||||
|
||||
if telegram_id is None:
|
||||
return await handler(event, data)
|
||||
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
is_reg_process = is_registration_process(event, current_state)
|
||||
|
||||
# Пропускаем пользователя на разрешенные ивенты
|
||||
if is_reg_process:
|
||||
return await handler(event, data)
|
||||
|
||||
bot: Bot = data["bot"]
|
||||
|
||||
channel_id = settings.CHANNEL_SUB_ID
|
||||
if not channel_id:
|
||||
return await handler(event, data)
|
||||
|
||||
channel_link = settings.CHANNEL_LINK
|
||||
try:
|
||||
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
||||
if member.status in self.BAD_MEMBER_STATUS:
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except (TelegramForbiddenError, TelegramBadRequest):
|
||||
# бот не админ или нет доступа к каналу
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
|
||||
# если все каналы пройдены
|
||||
return await handler(event, data)
|
||||
|
||||
@staticmethod
|
||||
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
|
||||
channel_sub_kb = get_channel_sub_keyboard(channel_link)
|
||||
text = f"""🔔 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!"""
|
||||
if isinstance(event, Message):
|
||||
return await event.answer(text, reply_markup=channel_sub_kb)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
||||
elif isinstance(event, Update) and event.message:
|
||||
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, CallbackQuery, Message
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GlobalErrorMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
try:
|
||||
return await handler(event, data)
|
||||
except TelegramBadRequest as e:
|
||||
return await self._handle_telegram_error(event, e)
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка в GlobalErrorMiddleware: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if self._is_old_query_error(error_message):
|
||||
return await self._handle_old_query(event, error)
|
||||
elif self._is_message_not_modified_error(error_message):
|
||||
return await self._handle_message_not_modified(event, error)
|
||||
elif self._is_bad_request_error(error_message):
|
||||
return await self._handle_bad_request(event, error)
|
||||
else:
|
||||
logger.error(f"Неизвестная Telegram API ошибка: {error}")
|
||||
raise error
|
||||
|
||||
def _is_old_query_error(self, error_message: str) -> bool:
|
||||
return any(phrase in error_message for phrase in [
|
||||
"query is too old",
|
||||
"query id is invalid",
|
||||
"response timeout expired"
|
||||
])
|
||||
|
||||
def _is_message_not_modified_error(self, error_message: str) -> bool:
|
||||
return "message is not modified" in error_message
|
||||
|
||||
def _is_bad_request_error(self, error_message: str) -> bool:
|
||||
return any(phrase in error_message for phrase in [
|
||||
"message not found",
|
||||
"chat not found",
|
||||
"bot was blocked by the user",
|
||||
"user is deactivated"
|
||||
])
|
||||
|
||||
async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
if isinstance(event, CallbackQuery):
|
||||
user_info = self._get_user_info(event)
|
||||
logger.warning(f"🕐 [GlobalErrorMiddleware] Игнорируем устаревший callback '{event.data}' от {user_info}")
|
||||
else:
|
||||
logger.warning(f"🕐 [GlobalErrorMiddleware] Игнорируем устаревший запрос: {error}")
|
||||
|
||||
return None
|
||||
|
||||
async def _handle_message_not_modified(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
logger.debug(f"📝 [GlobalErrorMiddleware] Сообщение не было изменено: {error}")
|
||||
|
||||
if isinstance(event, CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
logger.debug("✅ Успешно ответили на callback после 'message not modified'")
|
||||
except TelegramBadRequest as answer_error:
|
||||
if not self._is_old_query_error(str(answer_error).lower()):
|
||||
logger.error(f"❌ Ошибка при ответе на callback: {answer_error}")
|
||||
|
||||
return None
|
||||
|
||||
async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if "bot was blocked" in error_message:
|
||||
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else "Unknown"
|
||||
logger.info(f"🚫 [GlobalErrorMiddleware] Бот заблокирован пользователем {user_info}")
|
||||
return None
|
||||
elif "user is deactivated" in error_message:
|
||||
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else "Unknown"
|
||||
logger.info(f"👻 [GlobalErrorMiddleware] Пользователь деактивирован {user_info}")
|
||||
return None
|
||||
elif "chat not found" in error_message or "message not found" in error_message:
|
||||
logger.warning(f"🔍 [GlobalErrorMiddleware] Чат или сообщение не найдено: {error}")
|
||||
return None
|
||||
else:
|
||||
logger.error(f"❌ [GlobalErrorMiddleware] Неизвестная bad request ошибка: {error}")
|
||||
raise error
|
||||
|
||||
def _get_user_info(self, event: TelegramObject) -> str:
|
||||
if hasattr(event, 'from_user') and event.from_user:
|
||||
if event.from_user.username:
|
||||
return f"@{event.from_user.username}"
|
||||
else:
|
||||
return f"ID:{event.from_user.id}"
|
||||
return "Unknown"
|
||||
|
||||
|
||||
class ErrorStatisticsMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self):
|
||||
self.error_counts = {
|
||||
'old_queries': 0,
|
||||
'message_not_modified': 0,
|
||||
'bot_blocked': 0,
|
||||
'user_deactivated': 0,
|
||||
'other_errors': 0
|
||||
}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
try:
|
||||
return await handler(event, data)
|
||||
except TelegramBadRequest as e:
|
||||
self._count_error(e)
|
||||
raise
|
||||
|
||||
def _count_error(self, error: TelegramBadRequest):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if "query is too old" in error_message:
|
||||
self.error_counts['old_queries'] += 1
|
||||
elif "message is not modified" in error_message:
|
||||
self.error_counts['message_not_modified'] += 1
|
||||
elif "bot was blocked" in error_message:
|
||||
self.error_counts['bot_blocked'] += 1
|
||||
elif "user is deactivated" in error_message:
|
||||
self.error_counts['user_deactivated'] += 1
|
||||
else:
|
||||
self.error_counts['other_errors'] += 1
|
||||
|
||||
def get_statistics(self) -> dict:
|
||||
return self.error_counts.copy()
|
||||
|
||||
def reset_statistics(self):
|
||||
for key in self.error_counts:
|
||||
self.error_counts[key] = 0
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
@@ -707,4 +707,97 @@ class AdminNotificationService:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_update_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
update_type: str,
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
price_paid: int = 0
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
|
||||
}
|
||||
|
||||
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
|
||||
|
||||
message = f"""{title}
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
🔧 <b>Изменение:</b>
|
||||
📋 Параметр: {param_name}"""
|
||||
|
||||
if update_type == "servers":
|
||||
old_servers_info = await self._format_servers_detailed(old_value)
|
||||
new_servers_info = await self._format_servers_detailed(new_value)
|
||||
|
||||
message += f"""
|
||||
📉 Было: {old_servers_info}
|
||||
📈 Стало: {new_servers_info}"""
|
||||
else:
|
||||
message += f"""
|
||||
📉 Было: {self._format_update_value(old_value, update_type)}
|
||||
📈 Стало: {self._format_update_value(new_value, update_type)}"""
|
||||
|
||||
if price_paid > 0:
|
||||
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
|
||||
else:
|
||||
message += f"\n💸 Бесплатно"
|
||||
|
||||
message += f"""
|
||||
|
||||
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Рефер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
|
||||
return False
|
||||
|
||||
async def _format_servers_detailed(self, server_uuids: List[str]) -> str:
|
||||
if not server_uuids:
|
||||
return "Нет серверов"
|
||||
|
||||
try:
|
||||
from app.handlers.subscription import get_servers_display_names
|
||||
servers_names = await get_servers_display_names(server_uuids)
|
||||
|
||||
if servers_names and servers_names != "Нет серверов":
|
||||
return f"{len(server_uuids)} серверов ({servers_names})"
|
||||
else:
|
||||
return f"{len(server_uuids)} серверов"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка получения названий серверов для уведомления: {e}")
|
||||
return f"{len(server_uuids)} серверов"
|
||||
|
||||
def _format_update_value(self, value: Any, update_type: str) -> str:
|
||||
if update_type == "traffic":
|
||||
if value == 0:
|
||||
return "♾ Безлимитный"
|
||||
return f"{value} ГБ"
|
||||
elif update_type == "devices":
|
||||
return f"{value} устройств"
|
||||
elif update_type == "servers":
|
||||
if isinstance(value, list):
|
||||
return f"{len(value)} серверов"
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from dataclasses import dataclass, asdict
|
||||
import aiofiles
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, inspect
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -197,6 +198,8 @@ class BackupService:
|
||||
await self._send_backup_notification(
|
||||
"success", message, str(backup_path)
|
||||
)
|
||||
|
||||
await self._send_backup_file_to_chat(str(backup_path))
|
||||
|
||||
return True, message, str(backup_path)
|
||||
|
||||
@@ -536,9 +539,9 @@ class BackupService:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
async def _send_backup_notification(
|
||||
self,
|
||||
event_type: str,
|
||||
message: str,
|
||||
self,
|
||||
event_type: str,
|
||||
message: str,
|
||||
file_path: str = None
|
||||
):
|
||||
try:
|
||||
@@ -570,5 +573,32 @@ class BackupService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о бекапе: {e}")
|
||||
|
||||
async def _send_backup_file_to_chat(self, file_path: str):
|
||||
try:
|
||||
if not settings.is_backup_send_enabled():
|
||||
return
|
||||
|
||||
chat_id = settings.get_backup_send_chat_id()
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
send_kwargs = {
|
||||
'chat_id': chat_id,
|
||||
'document': FSInputFile(file_path),
|
||||
'caption': (
|
||||
f"📦 <b>Резервная копия</b>\n\n"
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
),
|
||||
'parse_mode': 'HTML'
|
||||
}
|
||||
|
||||
if settings.BACKUP_SEND_TOPIC_ID:
|
||||
send_kwargs['message_thread_id'] = settings.BACKUP_SEND_TOPIC_ID
|
||||
|
||||
await self.bot.send_document(**send_kwargs)
|
||||
logger.info(f"Бекап отправлен в чат {chat_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки бекапа в чат: {e}")
|
||||
|
||||
|
||||
backup_service = BackupService()
|
||||
|
||||
@@ -264,7 +264,14 @@ class MaintenanceService:
|
||||
self._is_checking = True
|
||||
self._status.last_check = datetime.utcnow()
|
||||
|
||||
api = RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY)
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
async with api:
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
@@ -158,8 +158,13 @@ class MonitoringService:
|
||||
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -693,7 +698,9 @@ class MonitoringService:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
limit: int = 50,
|
||||
event_type: Optional[str] = None
|
||||
event_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 20
|
||||
) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
@@ -703,7 +710,11 @@ class MonitoringService:
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
query = query.limit(limit)
|
||||
if page > 1 or per_page != 20:
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
else:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
@@ -723,25 +734,54 @@ class MonitoringService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов мониторинга: {e}")
|
||||
return []
|
||||
|
||||
async def get_monitoring_logs_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: Optional[str] = None
|
||||
) -> int:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
query = select(func.count(MonitoringLog.id))
|
||||
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
result = await db.execute(query)
|
||||
count = result.scalar()
|
||||
|
||||
return count or 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения количества логов: {e}")
|
||||
return 0
|
||||
|
||||
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
if days == 0:
|
||||
result = await db.execute(delete(MonitoringLog))
|
||||
else:
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
|
||||
deleted_count = result.rowcount
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Удалено {deleted_count} старых записей логов")
|
||||
if days == 0:
|
||||
logger.info(f"🗑️ Удалены все логи мониторинга ({deleted_count} записей)")
|
||||
else:
|
||||
logger.info(f"🗑️ Удалено {deleted_count} старых записей логов (старше {days} дней)")
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки логов: {e}")
|
||||
await db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+144
-14
@@ -4,7 +4,7 @@ import hmac
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice
|
||||
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -12,10 +12,16 @@ from app.services.yookassa_service import YooKassaService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.user import (
|
||||
add_user_balance,
|
||||
get_user_by_id,
|
||||
get_user_by_telegram_id,
|
||||
)
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.database.database import get_db
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -118,6 +124,34 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -126,7 +160,8 @@ class PaymentService:
|
||||
f"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {int(rubles_amount)}₽"
|
||||
@@ -309,6 +344,34 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -316,9 +379,12 @@ class PaymentService:
|
||||
f"🦊 Способ: Банковская карта\n"
|
||||
f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}₽"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
else:
|
||||
@@ -382,20 +448,55 @@ class PaymentService:
|
||||
telegram_id: int,
|
||||
amount_kopeks: int
|
||||
) -> None:
|
||||
|
||||
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
message = (f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: Банковская карта (YooKassa)\n\n"
|
||||
f"Средства зачислены на ваш баланс!")
|
||||
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
break
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
message = (
|
||||
f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: Банковская карта (YooKassa)\n\n"
|
||||
f"Средства зачислены на ваш баланс!"
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления пользователю {telegram_id}: {e}")
|
||||
@@ -641,6 +742,34 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -649,7 +778,8 @@ class PaymentService:
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {amount_rubles:.2f}₽ ({updated_payment.asset})")
|
||||
except Exception as e:
|
||||
|
||||
@@ -23,10 +23,13 @@ logger = logging.getLogger(__name__)
|
||||
class RemnaWaveService:
|
||||
|
||||
def __init__(self):
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=settings.REMNAWAVE_API_URL,
|
||||
api_key=settings.REMNAWAVE_API_KEY,
|
||||
secret_key=settings.REMNAWAVE_SECRET_KEY
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
def _parse_remnawave_date(self, date_str: str) -> datetime:
|
||||
@@ -351,7 +354,7 @@ class RemnaWaveService:
|
||||
|
||||
async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'inbounds': inbound_uuids
|
||||
@@ -776,6 +779,11 @@ class RemnaWaveService:
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
stats["updated"] += 1
|
||||
@@ -790,7 +798,11 @@ class RemnaWaveService:
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -873,7 +885,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
squad = await api.get_internal_squad_by_uuid(squad_uuid)
|
||||
if squad:
|
||||
return {
|
||||
@@ -890,7 +902,7 @@ class RemnaWaveService:
|
||||
|
||||
async def add_all_users_to_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
@@ -899,7 +911,7 @@ class RemnaWaveService:
|
||||
|
||||
async def remove_all_users_from_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
@@ -908,7 +920,7 @@ class RemnaWaveService:
|
||||
|
||||
async def delete_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
response = await api.delete_internal_squad(squad_uuid)
|
||||
return response
|
||||
except Exception as e:
|
||||
@@ -917,7 +929,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_all_inbounds(self) -> List[Dict]:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
response = await api._make_request('GET', '/api/config-profiles/inbounds')
|
||||
inbounds_data = response.get('response', {}).get('inbounds', [])
|
||||
|
||||
@@ -938,7 +950,7 @@ class RemnaWaveService:
|
||||
|
||||
async def rename_squad(self, squad_uuid: str, new_name: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'name': new_name
|
||||
@@ -951,7 +963,7 @@ class RemnaWaveService:
|
||||
|
||||
async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
squad = await api.create_internal_squad(name, inbound_uuids)
|
||||
return squad is not None
|
||||
except Exception as e:
|
||||
|
||||
@@ -23,9 +23,13 @@ logger = logging.getLogger(__name__)
|
||||
class SubscriptionService:
|
||||
|
||||
def __init__(self):
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=settings.REMNAWAVE_API_URL,
|
||||
api_key=settings.REMNAWAVE_API_KEY
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
async def create_remnawave_user(
|
||||
@@ -64,6 +68,11 @@ class SubscriptionService:
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -78,7 +87,11 @@ class SubscriptionService:
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -131,8 +144,13 @@ class SubscriptionService:
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.database.crud.transaction import (
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -218,7 +219,40 @@ class TributeService:
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
|
||||
async for session in get_db():
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
break
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
]
|
||||
)
|
||||
|
||||
text = (
|
||||
f"✅ **Платеж успешно получен!**\n\n"
|
||||
f"💰 Сумма: {int(amount_rubles)} ₽\n"
|
||||
@@ -226,12 +260,7 @@ class TributeService:
|
||||
f"🎉 Средства зачислены на баланс!\n\n"
|
||||
f"Спасибо за оплату! 🙏"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
|
||||
+9
-8
@@ -1,11 +1,9 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class RegistrationStates(StatesGroup):
|
||||
waiting_for_rules_accept = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class SubscriptionStates(StatesGroup):
|
||||
selecting_period = State()
|
||||
selecting_traffic = State()
|
||||
@@ -19,7 +17,6 @@ class SubscriptionStates(StatesGroup):
|
||||
extending_subscription = State()
|
||||
confirming_traffic_reset = State()
|
||||
|
||||
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
waiting_for_stars_payment = State()
|
||||
@@ -30,7 +27,6 @@ class PromoCodeStates(StatesGroup):
|
||||
waiting_for_code = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class AdminStates(StatesGroup):
|
||||
|
||||
waiting_for_user_search = State()
|
||||
@@ -47,6 +43,7 @@ class AdminStates(StatesGroup):
|
||||
setting_promocode_expiry = State()
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
waiting_for_broadcast_media = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
@@ -71,14 +68,11 @@ class AdminStates(StatesGroup):
|
||||
creating_server_country = State()
|
||||
|
||||
editing_welcome_text = State()
|
||||
|
||||
|
||||
|
||||
waiting_for_message_buttons = "waiting_for_message_buttons"
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
confirming_autopay_toggle = State()
|
||||
@@ -89,3 +83,10 @@ class SquadCreateStates(StatesGroup):
|
||||
|
||||
class SquadRenameStates(StatesGroup):
|
||||
waiting_for_new_name = State()
|
||||
|
||||
class AdminSubmenuStates(StatesGroup):
|
||||
in_users_submenu = State()
|
||||
in_promo_submenu = State()
|
||||
in_communications_submenu = State()
|
||||
in_settings_submenu = State()
|
||||
in_system_submenu = State()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery
|
||||
|
||||
from app.states import RegistrationStates
|
||||
|
||||
|
||||
def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool:
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
is_registration_process = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith("/start"))
|
||||
or (current_state in registration_states)
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (
|
||||
event.data in ["rules_accept", "rules_decline", "referral_skip"]
|
||||
or event.data in ["sub_channel_check"]
|
||||
)
|
||||
)
|
||||
)
|
||||
return is_registration_process
|
||||
+78
-25
@@ -3,6 +3,7 @@ import functools
|
||||
from typing import Callable, Any
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
@@ -25,10 +26,16 @@ def admin_required(func: Callable) -> Callable:
|
||||
if not user or not settings.is_admin(user.id):
|
||||
texts = get_texts()
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ACCESS_DENIED)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
try:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ACCESS_DENIED)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
except TelegramBadRequest as e:
|
||||
if "query is too old" in str(e).lower():
|
||||
logger.warning(f"Попытка ответить на устаревший callback query от {user.id if user else 'Unknown'}")
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.warning(f"Попытка доступа к админской функции от {user.id if user else 'Unknown'}")
|
||||
return
|
||||
@@ -44,28 +51,71 @@ def error_handler(func: Callable) -> Callable:
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except TelegramBadRequest as e:
|
||||
error_message = str(e).lower()
|
||||
|
||||
if "query is too old" in error_message or "query id is invalid" in error_message:
|
||||
event = _extract_event(args)
|
||||
if event and isinstance(event, types.CallbackQuery):
|
||||
user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}"
|
||||
logger.warning(f"🕐 Игнорируем устаревший callback '{event.data}' от {user_info} в {func.__name__}")
|
||||
else:
|
||||
logger.warning(f"🕐 Игнорируем устаревший запрос в {func.__name__}: {e}")
|
||||
return None
|
||||
|
||||
elif "message is not modified" in error_message:
|
||||
logger.debug(f"📝 Сообщение не изменено в {func.__name__}")
|
||||
event = _extract_event(args)
|
||||
if event and isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except TelegramBadRequest as answer_error:
|
||||
if "query is too old" not in str(answer_error).lower():
|
||||
logger.error(f"Ошибка при ответе на callback: {answer_error}")
|
||||
return None
|
||||
|
||||
else:
|
||||
logger.error(f"Telegram API error в {func.__name__}: {e}")
|
||||
await _send_error_message(args, kwargs, e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в {func.__name__}: {e}", exc_info=True)
|
||||
|
||||
event = None
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, (types.Message, types.CallbackQuery)):
|
||||
event = arg
|
||||
break
|
||||
|
||||
if event:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ERROR)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ERROR, show_alert=True)
|
||||
await _send_error_message(args, kwargs, e)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _extract_event(args) -> types.TelegramObject:
|
||||
for arg in args:
|
||||
if isinstance(arg, (types.Message, types.CallbackQuery)):
|
||||
return arg
|
||||
return None
|
||||
|
||||
|
||||
async def _send_error_message(args, kwargs, original_error):
|
||||
try:
|
||||
event = _extract_event(args)
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
if not event:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ERROR)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ERROR, show_alert=True)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
if "query is too old" in str(e).lower():
|
||||
logger.warning("Не удалось отправить сообщение об ошибке - callback query устарел")
|
||||
else:
|
||||
logger.error(f"Ошибка при отправке сообщения об ошибке: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка при отправке сообщения об ошибке: {e}")
|
||||
|
||||
|
||||
def state_cleanup(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
@@ -91,10 +141,13 @@ def typing_action(func: Callable) -> Callable:
|
||||
**kwargs
|
||||
) -> Any:
|
||||
if isinstance(event, types.Message):
|
||||
await event.bot.send_chat_action(
|
||||
chat_id=event.chat.id,
|
||||
action="typing"
|
||||
)
|
||||
try:
|
||||
await event.bot.send_chat_action(
|
||||
chat_id=event.chat.id,
|
||||
action="typing"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось отправить typing action: {e}")
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
@@ -114,4 +167,4 @@ def rate_limit(rate: float = 1.0, key: str = None):
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
return decorator
|
||||
|
||||
+86
-18
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, Tuple
|
||||
from datetime import datetime
|
||||
import html
|
||||
|
||||
@@ -14,6 +14,11 @@ ALLOWED_HTML_TAGS = {
|
||||
'blockquote'
|
||||
}
|
||||
|
||||
SELF_CLOSING_TAGS = {
|
||||
'br', 'hr', 'img'
|
||||
}
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
@@ -158,7 +163,8 @@ def validate_referral_code(code: str) -> bool:
|
||||
|
||||
return validate_promocode(code)
|
||||
|
||||
def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
|
||||
def validate_html_tags(text: str) -> Tuple[bool, str]:
|
||||
if not text:
|
||||
return True, ""
|
||||
|
||||
@@ -168,21 +174,34 @@ def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
if tag_name_lower not in ALLOWED_HTML_TAGS:
|
||||
if tag_name_lower not in ALLOWED_HTML_TAGS and tag_name_lower not in SELF_CLOSING_TAGS:
|
||||
return False, f"Неподдерживаемый тег: <{tag_name}>"
|
||||
|
||||
return validate_html_structure(text)
|
||||
|
||||
|
||||
def validate_html_structure(text: str) -> Tuple[bool, str]:
|
||||
tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*?/?>'
|
||||
|
||||
matches = re.finditer(tag_pattern, text)
|
||||
tag_stack = []
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
for match in matches:
|
||||
full_tag = match.group(0)
|
||||
is_closing = bool(match.group(1))
|
||||
tag_name = match.group(2).lower()
|
||||
|
||||
if not is_closing:
|
||||
tag_stack.append(tag_name_lower)
|
||||
else:
|
||||
if full_tag.endswith('/>') or tag_name in SELF_CLOSING_TAGS:
|
||||
continue
|
||||
|
||||
if not is_closing:
|
||||
tag_stack.append(tag_name)
|
||||
else:
|
||||
if not tag_stack:
|
||||
return False, f"Закрывающий тег без открывающего: </{tag_name}>"
|
||||
|
||||
last_tag = tag_stack.pop()
|
||||
if last_tag != tag_name_lower:
|
||||
if last_tag != tag_name:
|
||||
return False, f"Неправильная вложенность тегов: ожидался </{last_tag}>, найден </{tag_name}>"
|
||||
|
||||
if tag_stack:
|
||||
@@ -191,16 +210,65 @@ def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def fix_html_tags(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
|
||||
fixes = [
|
||||
(r'<a href=([^"\s>]+)>', r'<a href="\1">'),
|
||||
(r'<(br|hr|img[^>]*?)>', r'<\1 />'),
|
||||
(r'<<([^>]+)>>', r'<\1>'),
|
||||
(r'<\s+([^>]+)\s+>', r'<\1>'),
|
||||
]
|
||||
|
||||
result = text
|
||||
for pattern, replacement in fixes:
|
||||
result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_html_help_text() -> str:
|
||||
return """<b>Поддерживаемые HTML теги:</b>
|
||||
|
||||
- <code><b>жирный</b></code> или <code><strong>жирный</strong></code>
|
||||
- <code><i>курсив</i></code> или <code><em>курсив</em></code>
|
||||
- <code><u>подчеркнутый</u></code>
|
||||
- <code><s>зачеркнутый</s></code>
|
||||
- <code><code>моноширинный</code></code>
|
||||
- <code><pre>блок кода</pre></code>
|
||||
- <code><a href="url">ссылка</a></code>
|
||||
- <code><blockquote>цитата</blockquote></code>
|
||||
• <code><b>жирный</b></code> или <code><strong>жирный</strong></code>
|
||||
• <code><i>курсив</i></code> или <code><em>курсив</em></code>
|
||||
• <code><u>подчеркнутый</u></code>
|
||||
• <code><s>зачеркнутый</s></code>
|
||||
• <code><code>моноширинный</code></code>
|
||||
• <code><pre>блок кода</pre></code>
|
||||
• <code><a href="url">ссылка</a></code>
|
||||
• <code><blockquote>цитата</blockquote></code>
|
||||
|
||||
<b>Неподдерживаемые теги:</b> <br>, <p>, <div>, <span>, <spoiler> и другие"""
|
||||
<b>⚠️ Важные правила:</b>
|
||||
• Каждый открывающий тег должен быть закрыт
|
||||
• Теги должны быть правильно вложены
|
||||
• Атрибуты ссылок берите в кавычки
|
||||
|
||||
<b>❌ Неправильно:</b>
|
||||
<code><b>жирный <i>курсив</b></i></code>
|
||||
<code><a href=google.com>ссылка</a></code>
|
||||
|
||||
<b>✅ Правильно:</b>
|
||||
<code><b>жирный <i>курсив</i></b></code>
|
||||
<code><a href="https://google.com">ссылка</a></code>"""
|
||||
|
||||
|
||||
def validate_rules_content(text: str) -> Tuple[bool, str, Optional[str]]:
|
||||
if not text or not text.strip():
|
||||
return False, "Текст правил не может быть пустым", None
|
||||
|
||||
if len(text) > 4000:
|
||||
return False, f"Текст слишком длинный: {len(text)} символов (максимум 4000)", None
|
||||
|
||||
is_valid_html, html_error = validate_html_tags(text)
|
||||
if not is_valid_html:
|
||||
fixed_text = fix_html_tags(text)
|
||||
fixed_is_valid, _ = validate_html_tags(fixed_text)
|
||||
|
||||
if fixed_is_valid and fixed_text != text:
|
||||
return False, html_error, fixed_text
|
||||
else:
|
||||
return False, html_error, None
|
||||
|
||||
return True, "", None
|
||||
|
||||
@@ -86,7 +86,7 @@ async def main():
|
||||
admin_notification_service = AdminNotificationService(bot)
|
||||
version_service.bot = bot
|
||||
version_service.set_notification_service(admin_notification_service)
|
||||
logger.info(f"🔄 Сервис версий настроен для репозитория: {version_service.repo}")
|
||||
logger.info(f"📄 Сервис версий настроен для репозитория: {version_service.repo}")
|
||||
logger.info(f"📦 Текущая версия: {version_service.current_version}")
|
||||
|
||||
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
|
||||
@@ -95,7 +95,6 @@ async def main():
|
||||
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()
|
||||
@@ -107,12 +106,20 @@ async def main():
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info("🌐 Запуск Tribute webhook сервера...")
|
||||
webhook_needed = settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled()
|
||||
|
||||
if webhook_needed:
|
||||
enabled_services = []
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
enabled_services.append("Tribute")
|
||||
if settings.is_cryptobot_enabled():
|
||||
enabled_services.append("CryptoBot")
|
||||
|
||||
logger.info(f"🌐 Запуск webhook сервера для: {', '.join(enabled_services)}...")
|
||||
webhook_server = WebhookServer(bot)
|
||||
await webhook_server.start()
|
||||
else:
|
||||
logger.info("ℹ️ Tribute отключен, webhook сервер не запускается")
|
||||
logger.info("ℹ️ Tribute и CryptoBot отключены, webhook сервер не запускается")
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info("💳 Запуск YooKassa webhook сервера...")
|
||||
@@ -134,7 +141,7 @@ async def main():
|
||||
maintenance_task = None
|
||||
|
||||
if settings.is_version_check_enabled():
|
||||
logger.info("🔄 Запуск сервиса проверки версий...")
|
||||
logger.info("📄 Запуск сервиса проверки версий...")
|
||||
version_check_task = asyncio.create_task(version_service.start_periodic_check())
|
||||
else:
|
||||
logger.info("ℹ️ Проверка версий отключена")
|
||||
@@ -144,11 +151,14 @@ async def main():
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("🎯 Активные webhook endpoints:")
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if webhook_needed:
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
logger.info("🔄 Активные фоновые сервисы:")
|
||||
logger.info("📄 Активные фоновые сервисы:")
|
||||
logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}")
|
||||
logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}")
|
||||
logger.info(f" Проверка версий: {'Включен' if version_check_task else 'Отключен'}")
|
||||
@@ -252,7 +262,7 @@ async def main():
|
||||
pass
|
||||
|
||||
if webhook_server:
|
||||
logger.info("ℹ️ Остановка Tribute webhook сервера...")
|
||||
logger.info("ℹ️ Остановка webhook сервера...")
|
||||
await webhook_server.stop()
|
||||
|
||||
if 'bot' in locals():
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 716 KiB |
Reference in New Issue
Block a user